From b3de5f31e73fc129e2f03a2d787d743ea723660a Mon Sep 17 00:00:00 2001 From: unconst Date: Mon, 15 Jun 2026 15:30:56 -0600 Subject: [PATCH 1/8] Root Reborn: reinvest root dividends into validator-curated baskets Replace the per-block auto-sell of root dividends with a compounding, redeemable beta basket. Root validators set a distribution vector over subnets via `set_root_weights`; each validator's root dividends are sold to TAO and re-bought as alpha across those subnets, staked under a global escrow coldkey (so the basket counts toward the validator's stake and compounds), and redeemed to TAO on demand through the existing claim path using an E/P growth multiplier. Auto-claim/auto-sell removed. Adds a dedicated `RootBasketWeights` map, `BasketPrincipal` accounting, hotkey-swap and subnet-dissolve handling, a legacy-state seed migration, and RPC views (staker pending TAO, validator NAV + basket, network-wide NAV). Co-authored-by: Cursor --- pallets/subtensor/rpc/src/lib.rs | 94 +- pallets/subtensor/runtime-api/src/lib.rs | 11 + pallets/subtensor/src/coinbase/block_step.rs | 4 +- .../subtensor/src/coinbase/run_coinbase.rs | 10 +- pallets/subtensor/src/lib.rs | 30 + pallets/subtensor/src/macros/dispatches.rs | 20 + pallets/subtensor/src/macros/events.rs | 2 + pallets/subtensor/src/macros/hooks.rs | 4 +- .../migrations/migrate_seed_beta_basket.rs | 114 + pallets/subtensor/src/migrations/mod.rs | 1 + pallets/subtensor/src/staking/claim_root.rs | 540 +++- pallets/subtensor/src/subnets/weights.rs | 82 + pallets/subtensor/src/swap/swap_hotkey.rs | 47 + pallets/subtensor/src/tests/claim_root.rs | 2318 +++++------------ pallets/subtensor/src/tests/migration.rs | 61 + .../src/tests/swap_hotkey_with_subnet.rs | 28 +- runtime/src/lib.rs | 15 + 17 files changed, 1618 insertions(+), 1763 deletions(-) create mode 100644 pallets/subtensor/src/migrations/migrate_seed_beta_basket.rs diff --git a/pallets/subtensor/rpc/src/lib.rs b/pallets/subtensor/rpc/src/lib.rs index e00729151f..08140eaa6a 100644 --- a/pallets/subtensor/rpc/src/lib.rs +++ b/pallets/subtensor/rpc/src/lib.rs @@ -14,8 +14,8 @@ use subtensor_runtime_common::{MechId, NetUid, TaoBalance}; use sp_api::ProvideRuntimeApi; pub use subtensor_custom_rpc_runtime_api::{ - DelegateInfoRuntimeApi, NeuronInfoRuntimeApi, StakeInfoRuntimeApi, SubnetInfoRuntimeApi, - SubnetRegistrationRuntimeApi, + BetaBasketRuntimeApi, DelegateInfoRuntimeApi, NeuronInfoRuntimeApi, StakeInfoRuntimeApi, + SubnetInfoRuntimeApi, SubnetRegistrationRuntimeApi, }; #[rpc(client, server)] @@ -118,6 +118,31 @@ pub trait SubtensorCustomApi { netuid: NetUid, at: Option, ) -> RpcResult>; + + /// Total TAO a staker (coldkey) would realize by redeeming all its root beta baskets. + #[method(name = "betaBasket_getStakerOwed")] + fn get_root_basket_owed( + &self, + coldkey: AccountId32, + at: Option, + ) -> RpcResult; + /// A validator's beta basket net asset value, in TAO. + #[method(name = "betaBasket_getValidatorNav")] + fn get_validator_basket_nav( + &self, + hotkey: AccountId32, + at: Option, + ) -> RpcResult; + /// A validator's full basket breakdown: SCALE-encoded `Vec<(NetUid, AlphaBalance, TaoBalance)>`. + #[method(name = "betaBasket_getValidatorBasket")] + fn get_validator_basket( + &self, + hotkey: AccountId32, + at: Option, + ) -> RpcResult>; + /// Network-wide total beta basket NAV across all validators, in TAO. + #[method(name = "betaBasket_getTotalNav")] + fn get_root_basket_total_nav(&self, at: Option) -> RpcResult; } pub struct SubtensorCustom { @@ -167,6 +192,7 @@ where C::Api: SubnetInfoRuntimeApi, C::Api: StakeInfoRuntimeApi, C::Api: SubnetRegistrationRuntimeApi, + C::Api: BetaBasketRuntimeApi, { fn get_delegates(&self, at: Option<::Hash>) -> RpcResult> { let api = self.client.runtime_api(); @@ -572,4 +598,68 @@ where Err(e) => Err(Error::RuntimeError(format!("Unable to get coldkey lock: {e:?}")).into()), } } + + fn get_root_basket_owed( + &self, + coldkey: AccountId32, + at: Option<::Hash>, + ) -> RpcResult { + let api = self.client.runtime_api(); + let at = at.unwrap_or_else(|| self.client.info().best_hash); + + match api.get_root_basket_owed(at, coldkey) { + Ok(result) => Ok(result), + Err(e) => { + Err(Error::RuntimeError(format!("Unable to get root basket owed: {e:?}")).into()) + } + } + } + + fn get_validator_basket_nav( + &self, + hotkey: AccountId32, + at: Option<::Hash>, + ) -> RpcResult { + let api = self.client.runtime_api(); + let at = at.unwrap_or_else(|| self.client.info().best_hash); + + match api.get_validator_basket_nav(at, hotkey) { + Ok(result) => Ok(result), + Err(e) => Err(Error::RuntimeError(format!( + "Unable to get validator basket NAV: {e:?}" + )) + .into()), + } + } + + fn get_validator_basket( + &self, + hotkey: AccountId32, + at: Option<::Hash>, + ) -> RpcResult> { + let api = self.client.runtime_api(); + let at = at.unwrap_or_else(|| self.client.info().best_hash); + + match api.get_validator_basket(at, hotkey) { + Ok(result) => Ok(result.encode()), + Err(e) => { + Err(Error::RuntimeError(format!("Unable to get validator basket: {e:?}")).into()) + } + } + } + + fn get_root_basket_total_nav( + &self, + at: Option<::Hash>, + ) -> RpcResult { + let api = self.client.runtime_api(); + let at = at.unwrap_or_else(|| self.client.info().best_hash); + + match api.get_root_basket_total_nav(at) { + Ok(result) => Ok(result), + Err(e) => { + Err(Error::RuntimeError(format!("Unable to get total basket NAV: {e:?}")).into()) + } + } + } } diff --git a/pallets/subtensor/runtime-api/src/lib.rs b/pallets/subtensor/runtime-api/src/lib.rs index 0fb24d61c2..2b8f08116a 100644 --- a/pallets/subtensor/runtime-api/src/lib.rs +++ b/pallets/subtensor/runtime-api/src/lib.rs @@ -81,4 +81,15 @@ sp_api::decl_runtime_apis! { fn get_proxy_types() -> Vec; fn get_proxy_filter(proxy_type: Option) -> Vec; } + + pub trait BetaBasketRuntimeApi { + /// Total TAO a coldkey would realize by redeeming all its root beta baskets (marked). + fn get_root_basket_owed(coldkey: AccountId32) -> TaoBalance; + /// A validator's beta basket net asset value, in TAO (marked). + fn get_validator_basket_nav(hotkey: AccountId32) -> TaoBalance; + /// A validator's basket breakdown: (subnet, alpha held, TAO value) per subnet. + fn get_validator_basket(hotkey: AccountId32) -> Vec<(NetUid, AlphaBalance, TaoBalance)>; + /// Network-wide total beta basket NAV across all validators, in TAO (marked). + fn get_root_basket_total_nav() -> TaoBalance; + } } diff --git a/pallets/subtensor/src/coinbase/block_step.rs b/pallets/subtensor/src/coinbase/block_step.rs index fac924ccf4..31c61bf00c 100644 --- a/pallets/subtensor/src/coinbase/block_step.rs +++ b/pallets/subtensor/src/coinbase/block_step.rs @@ -6,7 +6,6 @@ impl Pallet { /// Executes the necessary operations for each block. pub fn block_step() -> Result<(), &'static str> { let block_number: u64 = Self::get_current_block_as_u64(); - let last_block_hash: T::Hash = >::parent_hash(); // --- 1. Update registration burn prices. Self::update_registration_prices_for_networks(); @@ -25,8 +24,7 @@ impl Pallet { Self::update_root_prop(); // --- 7. Set pending children on the epoch; but only after the coinbase has been run. Self::try_set_pending_children(block_number); - // --- 8. Run auto-claim root divs. - Self::run_auto_claim_root_divs(last_block_hash); + // --- 8. Beta baskets are redeemed on-demand by stakers via `claim_root`; no auto-swap. // --- 9. Populate root coldkey maps. Self::populate_root_coldkey_staking_maps(); Self::populate_root_coldkey_staking_maps_v2(); diff --git a/pallets/subtensor/src/coinbase/run_coinbase.rs b/pallets/subtensor/src/coinbase/run_coinbase.rs index 494fc163f7..20405631a8 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -705,11 +705,11 @@ impl Pallet { tou64!(alpha_take).into(), ); - Self::increase_root_claimable_for_hotkey_and_subnet( - &hotkey, - netuid, - tou64!(root_alpha).into(), - ); + // Distribute the validator's root dividend into its beta basket across subnets + // per the validator's root weight vector (set on subnet 0). The bought basket + // alpha is staked to the validator under the global escrow coldkey, so it counts + // toward the validator's stake and compounds; stakers accrue a claimable rate. + Self::distribute_root_alpha_to_basket(&hotkey, netuid, tou64!(root_alpha).into()); // Record root alpha dividends for this validator on this subnet. RootAlphaDividendsPerSubnet::::mutate(netuid, &hotkey, |divs| { diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 7ce25b65b6..f9c9f95f39 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -2488,6 +2488,36 @@ pub mod pallet { u128, ValueQuery, >; + + /// --- DMAP ( validator_hotkey, netuid ) --> outstanding basket principal (alpha). + /// + /// Total un-claimed alpha *principal* that root stakers have contributed to this + /// validator's beta basket on `netuid`. The actual basket alpha is staked to the + /// validator under the global beta escrow coldkey and grows with dividends; the + /// per-staker payout at claim time is `owed_principal * (escrow_value / BasketPrincipal)`, + /// which captures that compounding. Kept in alpha (not shares) so it survives hotkey + /// swaps, where positions migrate by value. + #[pallet::storage] + pub type BasketPrincipal = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Identity, + NetUid, + AlphaBalance, + ValueQuery, + DefaultZeroAlpha, + >; + + /// --- MAP ( validator_hotkey ) --> Vec<(subnet_id, weight)> | beta basket distribution vector. + /// + /// A root validator's beta-basket weight vector `w`, set via `set_root_weights`. Dedicated + /// storage (NOT the legacy `Weights[ROOT]` consensus map) so basket allocation never aliases + /// or is mutated by root-consensus / `remove_network` weight handling. Keyed by hotkey so it + /// is unaffected by root UID reuse and migrates cleanly on hotkey swap. + #[pallet::storage] + pub type RootBasketWeights = + StorageMap<_, Blake2_128Concat, T::AccountId, Vec<(u16, u16)>, ValueQuery>; #[pallet::storage] // -- MAP ( cold ) --> root_claim_type enum pub type RootClaimType = StorageMap< _, diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 7a64acba44..47c2eeb5df 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -97,6 +97,26 @@ mod dispatches { } } + /// --- Sets a root validator's beta-basket distribution vector `w` on the root subnet + /// (netuid 0). `dests` are subnet netuids and `weights` are the proportions of the + /// validator's root dividends to deploy into each subnet's alpha basket. + /// + /// # Args: + /// * `origin`: the root validator hotkey. + /// * `dests` (Vec): destination subnet netuids. + /// * `weights` (Vec): per-subnet weights (normalized on use). + /// * `version_key` (u64): the network version key. + #[pallet::call_index(139)] + #[pallet::weight((::WeightInfo::set_weights(), DispatchClass::Normal, Pays::No))] + pub fn set_root_weights( + origin: OriginFor, + dests: Vec, + weights: Vec, + version_key: u64, + ) -> DispatchResult { + Self::do_set_root_weights(origin, dests, weights, version_key) + } + /// --- Sets the caller weights for the incentive mechanism for mechanisms. The call /// can be made from the hotkey account so is potentially insecure, however, the damage /// of changing weights is minimal if caught early. This function includes all the diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 918baf1107..23f121e535 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -42,6 +42,8 @@ mod events { ), /// a caller successfully sets their weights on a subnetwork. WeightsSet(NetUidStorageIndex, u16), + /// a root validator set its beta-basket distribution vector (uid on the root subnet). + RootWeightsSet(u16), /// a new neuron account has been registered to the chain. NeuronRegistered(NetUid, u16, T::AccountId), /// multiple uids have been concurrently registered. diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 869d6074da..44ffbc1b25 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -175,7 +175,9 @@ mod hooks { // Capture the runtime-upgrade block for TAO-in refund cutover. .saturating_add(migrations::migrate_tao_in_refund_deployment_block::migrate_tao_in_refund_deployment_block::()) // Fix lock state left behind by subnet-scoped hotkey swaps. - .saturating_add(migrations::migrate_fix_subnet_hotkey_lock_swaps::migrate_fix_subnet_hotkey_lock_swaps::()); + .saturating_add(migrations::migrate_fix_subnet_hotkey_lock_swaps::migrate_fix_subnet_hotkey_lock_swaps::()) + // Seed the beta-basket escrow model from legacy RootClaimable state. + .saturating_add(migrations::migrate_seed_beta_basket::migrate_seed_beta_basket::()); weight } diff --git a/pallets/subtensor/src/migrations/migrate_seed_beta_basket.rs b/pallets/subtensor/src/migrations/migrate_seed_beta_basket.rs new file mode 100644 index 0000000000..0257cde4fd --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_seed_beta_basket.rs @@ -0,0 +1,114 @@ +use super::*; +use frame_support::pallet_prelude::Weight; +use scale_info::prelude::string::String; +use substrate_fixed::types::I96F32; +use subtensor_runtime_common::{AlphaBalance, NetUid}; + +/// Seeds the beta-basket escrow model from pre-existing legacy `RootClaimable` state. +/// +/// Before this feature, a validator's root dividends accrued as a per-subnet *rate* +/// (`RootClaimable[hotkey][netuid]`, alpha-per-root-stake) backed by unattributed +/// outstanding alpha in `SubnetAlphaOut`. The beta basket instead backs each slot with a +/// real escrow stake position `(hotkey, escrow, netuid)` and an outstanding-principal +/// counter `BasketPrincipal`, paying out `owed * (escrow_value / principal)`. +/// +/// If legacy slots were left unseeded, two problems arise: +/// 1. Claims compute `payout = owed * E/P` with `P = 0` → payout `0` → legacy dividends strand. +/// 2. If a legacy slot later receives new accrual, the shared rate mixes legacy + new while +/// `E/P` only tracks the new portion, breaking the `SubnetAlphaOut` ↔ stake invariant. +/// +/// This migration converts every legacy slot to the escrow model with `E = P = remaining`, +/// where `remaining = rate * total_root_stake - Σ already-claimed`. It stakes that remaining +/// (previously unattributed) outstanding alpha to the validator under the escrow coldkey and +/// records it as basket principal, leaving the rate and per-coldkey `RootClaimed` watermarks +/// intact so existing per-staker owed amounts pay out unchanged (`E/P = 1`), then compound. +/// +/// NOTE: this scans `RootClaimed` per `(netuid, hotkey)` to total already-claimed amounts. +/// On a large state this is heavy; if it cannot fit a single block it should be converted to a +/// multi-block migration before mainnet deployment. +pub fn migrate_seed_beta_basket() -> Weight { + let migration_name = b"migrate_seed_beta_basket".to_vec(); + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(&migration_name) { + log::info!( + "Migration '{:?}' has already run. Skipping.", + String::from_utf8_lossy(&migration_name) + ); + return weight; + } + + log::info!( + "Running migration '{}'", + String::from_utf8_lossy(&migration_name) + ); + + let escrow = Pallet::::get_beta_escrow_account_id(); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + let hotkeys: Vec = RootClaimable::::iter_keys().collect(); + weight.saturating_accrue(T::DbWeight::get().reads(hotkeys.len() as u64)); + + let mut seeded_slots: u64 = 0; + + for hotkey in hotkeys.iter() { + let total_root: I96F32 = I96F32::saturating_from_num( + Pallet::::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT), + ); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + if total_root <= I96F32::saturating_from_num(0) { + continue; + } + + let claimable = RootClaimable::::get(hotkey); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + for (netuid, rate) in claimable.iter() { + if netuid.is_root() { + continue; + } + + // Gross credited principal = rate * total_root_stake. + let gross: I96F32 = rate.saturating_mul(total_root); + + // Total already claimed by all coldkeys on this (netuid, hotkey). + let mut claimed_sum: I96F32 = I96F32::saturating_from_num(0); + for (_coldkey, claimed) in RootClaimed::::iter_prefix((*netuid, hotkey)) { + claimed_sum = claimed_sum.saturating_add(I96F32::saturating_from_num(claimed)); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + } + + // Remaining unclaimed (still-outstanding) principal. + let remaining_f: I96F32 = gross.saturating_sub(claimed_sum); + let remaining: u64 = if remaining_f.is_negative() { + 0 + } else { + remaining_f.saturating_to_num::() + }; + if remaining == 0 { + continue; + } + let remaining_alpha = AlphaBalance::from(remaining); + + // Attribute the previously-unattributed outstanding alpha to the validator under the + // escrow coldkey (this becomes the basket), and record it as basket principal. + Pallet::::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + *netuid, + remaining_alpha, + ); + BasketPrincipal::::insert(hotkey, *netuid, remaining_alpha); + weight.saturating_accrue(T::DbWeight::get().writes(2)); + seeded_slots = seeded_slots.saturating_add(1); + } + } + + HasMigrationRun::::insert(&migration_name, true); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + + log::info!("Migration 'migrate_seed_beta_basket' completed. Seeded {seeded_slots} slots."); + + weight +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index c8f5e3994a..3608739907 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -54,6 +54,7 @@ pub mod migrate_reset_bonds_moving_average; pub mod migrate_reset_max_burn; pub mod migrate_reset_tnet_conviction_locks; pub mod migrate_reset_unactive_sn; +pub mod migrate_seed_beta_basket; pub mod migrate_set_first_emission_block_number; pub mod migrate_set_min_burn; pub mod migrate_set_min_difficulty; diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 38c2da914d..fc175a44bc 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -4,8 +4,9 @@ use frame_support::storage::{TransactionOutcome, with_transaction}; use frame_support::weights::Weight; use sp_core::Get; use sp_runtime::DispatchError; +use sp_runtime::traits::AccountIdConversion; use sp_std::collections::btree_set::BTreeSet; -use substrate_fixed::types::I96F32; +use substrate_fixed::types::{I96F32, U96F32}; use subtensor_swap_interface::SwapHandler; impl Pallet { @@ -64,6 +65,16 @@ impl Pallet { } // Increment claimable for this subnet. + Self::bump_root_claimable_rate(hotkey, netuid, increment); + } + + /// Adds `increment` (alpha-principal per unit of root stake) to a hotkey's claimable + /// rate on `netuid`. This is the unit-agnostic core shared by the legacy single-subnet + /// crediting and the beta basket distribution. + pub fn bump_root_claimable_rate(hotkey: &T::AccountId, netuid: NetUid, increment: I96F32) { + if increment == I96F32::saturating_from_num(0) { + return; + } RootClaimable::::mutate(hotkey, |claimable| { claimable .entry(netuid) @@ -72,6 +83,147 @@ impl Pallet { }); } + /// The single global escrow coldkey that custodies every validator's beta basket. + /// + /// Baskets are held as 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") + } + + /// 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). + /// + /// 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. Each + /// slot records the bought alpha as basket principal and bumps the per-staker claimable rate. + /// The whole operation is transactional: if any swap fails, it is rolled back and the original + /// alpha is recycled. If the validator has no usable weights (or no root stake), the dividend + /// is recycled. + pub fn distribute_root_alpha_to_basket( + hotkey: &T::AccountId, + origin_netuid: NetUid, + root_alpha: AlphaBalance, + ) { + if root_alpha.is_zero() { + return; + } + + // Resolve the validator's beta basket weight vector w (dedicated storage). + let weights = RootBasketWeights::::get(hotkey); + + // Keep only weights that point at existing, non-root subnets. + let valid: Vec<(NetUid, u64)> = weights + .into_iter() + .filter_map(|(dest, weight)| { + let dest_netuid = NetUid::from(dest); + if weight > 0 && !dest_netuid.is_root() && Self::if_subnet_exist(dest_netuid) { + Some((dest_netuid, weight as u64)) + } else { + None + } + }) + .collect(); + + let weight_sum: u64 = valid.iter().map(|(_, w)| *w).sum(); + let total_root = Self::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT); + + // No usable weights or no root stake to apportion against: recycle. + if valid.is_empty() || weight_sum == 0 || total_root.is_zero() { + Self::recycle_subnet_alpha(origin_netuid, root_alpha); + return; + } + + let total_root_float = I96F32::saturating_from_num(total_root); + let escrow = Self::get_beta_escrow_account_id(); + + let outcome = with_transaction(|| { + // 1. Sell the origin-subnet alpha for TAO. + let tao_total: TaoBalance = match Self::swap_alpha_for_tao( + origin_netuid, + root_alpha, + T::SwapInterface::min_price::(), + true, + ) { + Ok(res) => res.amount_paid_out, + Err(err) => return TransactionOutcome::Rollback(Err(err)), + }; + + // 2. Split the TAO across subnets per w and buy each subnet's alpha. + let tao_total_u64: u64 = tao_total.to_u64(); + let mut spent: u64 = 0; + let last_idx = valid.len().saturating_sub(1); + for (i, (dest_netuid, weight)) in valid.iter().enumerate() { + // Last slot absorbs the rounding remainder so Σ tao_s == tao_total exactly. + let tao_s: u64 = if i == last_idx { + tao_total_u64.saturating_sub(spent) + } else { + U96F32::saturating_from_num(tao_total_u64) + .saturating_mul(U96F32::saturating_from_num(*weight)) + .checked_div(U96F32::saturating_from_num(weight_sum)) + .unwrap_or(U96F32::saturating_from_num(0)) + .saturating_to_num::() + }; + spent = spent.saturating_add(tao_s); + if tao_s == 0 { + continue; + } + + let bought: AlphaBalance = match Self::swap_tao_for_alpha( + *dest_netuid, + tao_s.into(), + T::SwapInterface::max_price(), + true, + ) { + Ok(res) => res.amount_paid_out, + Err(err) => return TransactionOutcome::Rollback(Err(err)), + }; + if bought.is_zero() { + continue; + } + + // Per-staker claimable rate increment: bought alpha per unit of root stake. + let increment: I96F32 = I96F32::saturating_from_num(bought) + .checked_div(total_root_float) + .unwrap_or(I96F32::saturating_from_num(0)); + + // If the increment underflows to zero (bought is tiny relative to the root pool), + // crediting would grow principal/escrow with no claimable rate, stranding the + // value. Recycle this slot's alpha instead, keeping `Σ owed == BasketPrincipal` + // exact. (TAO stays neutral: the buy's `tao_s` already balances the origin sell.) + if increment == I96F32::saturating_from_num(0) { + Self::recycle_subnet_alpha(*dest_netuid, bought); + continue; + } + + // Stake the bought alpha to the validator under the escrow coldkey. + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + *dest_netuid, + bought, + ); + + // Record basket principal (alpha) for the E/P compounding multiplier. + BasketPrincipal::::mutate(hotkey, *dest_netuid, |p| { + *p = p.saturating_add(bought); + }); + + Self::bump_root_claimable_rate(hotkey, *dest_netuid, increment); + } + + TransactionOutcome::Commit(Ok(())) + }); + + // On any failure the swaps were rolled back; recycle the original alpha. + if outcome.is_err() { + Self::recycle_subnet_alpha(origin_netuid, root_alpha); + } + } + pub fn get_root_claimable_for_hotkey_coldkey( hotkey: &T::AccountId, coldkey: &T::AccountId, @@ -127,133 +279,142 @@ impl Pallet { owed_u64 } + /// Claims (redeems) a staker's share of a validator's beta basket on `netuid`. + /// + /// Redemption is always a full swap to TAO: the staker's owed *principal* is scaled by the + /// basket's live growth multiplier `E / P` (escrow value over outstanding principal) to get + /// the current payout, that payout alpha is removed from the escrow position, swapped to TAO, + /// and staked on root for the staker. `root_claim_type` is retained for signature + /// compatibility but no longer branches behavior (Keep was removed). pub fn root_claim_on_subnet( hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid, - root_claim_type: RootClaimTypeEnum, + _root_claim_type: RootClaimTypeEnum, ignore_minimum_condition: bool, ) -> DispatchResult { - // Subtract the root claimed. + // Owed *principal* (alpha) = rate * root_stake - already-claimed. let owed: I96F32 = Self::get_root_owed_for_hotkey_coldkey_float(hotkey, coldkey, netuid); - - if !ignore_minimum_condition - && owed < I96F32::saturating_from_num(RootClaimableThreshold::::get(&netuid)) - { - log::debug!( - "root claim on subnet {netuid} is skipped: {owed:?} for h={hotkey:?},c={coldkey:?} " - ); - return Ok(()); // no-op - } - - // Convert owed to u64, mapping negative values to 0 - let owed_u64: u64 = if owed.is_negative() { + let owed_principal: u64 = if owed.is_negative() { 0 } else { owed.saturating_to_num::() }; - - if owed_u64 == 0 { - log::debug!( - "root claim on subnet {netuid} is skipped: {owed:?} for h={hotkey:?},c={coldkey:?}" - ); + if owed_principal == 0 { return Ok(()); // no-op } - let swap = match root_claim_type { - RootClaimTypeEnum::Swap => true, - RootClaimTypeEnum::Keep => false, - RootClaimTypeEnum::KeepSubnets { subnets } => !subnets.contains(&netuid), - }; + // Live basket value via the escrow position, and outstanding principal. + let escrow = Self::get_beta_escrow_account_id(); + let escrow_value: u64 = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, netuid).to_u64(); + let principal_total: u64 = BasketPrincipal::::get(hotkey, netuid).to_u64(); - if swap { - with_transaction(|| { - // Increase stake on root. Swap the alpha owed to TAO. - let owed_tao = match Self::swap_alpha_for_tao( - netuid, - owed_u64.into(), - T::SwapInterface::min_price::(), - true, - ) { - Ok(owed_tao) => owed_tao, - Err(err) => { - log::error!("Error swapping alpha for TAO: {err:?}"); + // Payout = owed_principal * (E / P), capped at the live escrow value. + let payout: u64 = Self::basket_payout_from(owed_principal, escrow_value, principal_total); - return TransactionOutcome::Rollback(Err(err)); - } - }; + // Skip dust unless forced. + if !ignore_minimum_condition + && I96F32::saturating_from_num(payout) + < I96F32::saturating_from_num(RootClaimableThreshold::::get(&netuid)) + { + log::debug!( + "root claim on subnet {netuid} skipped (below threshold): payout={payout:?} h={hotkey:?} c={coldkey:?}" + ); + return Ok(()); // no-op + } - let root_subnet_account_id = match Self::get_subnet_account_id(NetUid::ROOT) { - Some(account_id) => account_id, - None => { - return TransactionOutcome::Rollback(Err( - Error::::RootNetworkDoesNotExist.into(), - )); - } - }; + // Nothing realizable yet (basket drained / zero value); leave the watermark untouched + // so it can be claimed once the basket has value. + if payout == 0 { + return Ok(()); + } - if let Err(err) = Self::transfer_tao_from_subnet( - netuid, - &root_subnet_account_id, - owed_tao.amount_paid_out.into(), - ) { - log::error!("Error transferring root claim TAO from subnet: {err:?}"); + with_transaction(|| { + // Remove the payout alpha from the validator's basket (escrow position). + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + netuid, + payout.into(), + ); + // Swap the basket alpha to TAO. + let owed_tao = match Self::swap_alpha_for_tao( + netuid, + payout.into(), + T::SwapInterface::min_price::(), + true, + ) { + Ok(owed_tao) => owed_tao, + Err(err) => { + log::error!("Error swapping basket alpha for TAO: {err:?}"); return TransactionOutcome::Rollback(Err(err)); } + }; + + let root_subnet_account_id = match Self::get_subnet_account_id(NetUid::ROOT) { + Some(account_id) => account_id, + None => { + return TransactionOutcome::Rollback(Err( + Error::::RootNetworkDoesNotExist.into() + )); + } + }; - // Record root sell as protocol outflow (reduces protocol cost). - let root_sell_tao: TaoBalance = owed_tao.amount_paid_out; - SubnetRootSellTao::::mutate(netuid, |total| { - *total = total.saturating_add(root_sell_tao); - }); - Self::record_protocol_outflow(netuid, root_sell_tao); + if let Err(err) = Self::transfer_tao_from_subnet( + netuid, + &root_subnet_account_id, + owed_tao.amount_paid_out.into(), + ) { + log::error!("Error transferring root claim TAO from subnet: {err:?}"); + return TransactionOutcome::Rollback(Err(err)); + } - Self::increase_stake_for_hotkey_and_coldkey_on_subnet( - hotkey, - coldkey, - NetUid::ROOT, - owed_tao.amount_paid_out.to_u64().into(), - ); + // Record root sell as protocol outflow (reduces protocol cost). + let root_sell_tao: TaoBalance = owed_tao.amount_paid_out; + SubnetRootSellTao::::mutate(netuid, |total| { + *total = total.saturating_add(root_sell_tao); + }); + Self::record_protocol_outflow(netuid, root_sell_tao); - // Increase root subnet SubnetTAO - SubnetTAO::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); - }); + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + owed_tao.amount_paid_out.to_u64().into(), + ); - // Increase root SubnetAlphaOut - SubnetAlphaOut::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(u64::from(owed_tao.amount_paid_out).into()); - }); + // Increase root subnet SubnetTAO + SubnetTAO::::mutate(NetUid::ROOT, |total| { + *total = total.saturating_add(owed_tao.amount_paid_out.into()); + }); - // Increase Total Stake - TotalStake::::mutate(|total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); - }); + // Increase root SubnetAlphaOut + SubnetAlphaOut::::mutate(NetUid::ROOT, |total| { + *total = total.saturating_add(u64::from(owed_tao.amount_paid_out).into()); + }); - Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( - hotkey, - coldkey, - owed_tao.amount_paid_out.into(), - ); + // Increase Total Stake + TotalStake::::mutate(|total| { + *total = total.saturating_add(owed_tao.amount_paid_out.into()); + }); - TransactionOutcome::Commit(Ok(())) - })?; - } else - /* Keep */ - { - // Increase the stake with the alpha owned - Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( hotkey, coldkey, - netuid, - owed_u64.into(), + owed_tao.amount_paid_out.into(), ); - } - // Increase root claimed by owed amount. + TransactionOutcome::Commit(Ok(())) + })?; + + // Consume the claimed principal from the basket and advance the watermark. + BasketPrincipal::::mutate(hotkey, netuid, |p| { + *p = p.saturating_sub(owed_principal.into()); + }); RootClaimed::::mutate((netuid, hotkey, coldkey), |root_claimed| { - *root_claimed = root_claimed.saturating_add(owed_u64.into()); + *root_claimed = root_claimed.saturating_add(owed_principal.into()); }); Ok(()) @@ -452,11 +613,97 @@ impl Pallet { RootClaimable::::insert(new_hotkey, dst_root_claimable); } + /// Liquidates a validator's beta basket on `netuid` back to its root stakers. + /// + /// Used when a subnet is dissolved: the escrow position `(hotkey, H, netuid)` is removed, + /// swapped to TAO, and credited to the validator's root nominators (proportional to their + /// root stake) via the root share pool — so basket value reaches the actual stakers instead + /// of being orphaned in the escrow account by subnet teardown. Best-effort: swap failures are + /// logged and the slot is left for subnet teardown to handle. + pub fn liquidate_basket_to_root_stakers( + hotkey: &T::AccountId, + escrow: &T::AccountId, + netuid: NetUid, + ) { + let basket_alpha = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, escrow, netuid); + if basket_alpha.is_zero() { + return; + } + + let _ = with_transaction(|| { + // Remove the basket alpha from the escrow position. + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + escrow, + netuid, + basket_alpha, + ); + + // Swap the basket alpha to TAO. + let owed_tao = match Self::swap_alpha_for_tao( + netuid, + basket_alpha, + T::SwapInterface::min_price::(), + true, + ) { + Ok(owed_tao) => owed_tao, + Err(err) => { + log::error!("Error liquidating basket alpha for TAO: {err:?}"); + return TransactionOutcome::Rollback(Err(err)); + } + }; + + let root_subnet_account_id = match Self::get_subnet_account_id(NetUid::ROOT) { + Some(account_id) => account_id, + None => { + return TransactionOutcome::Rollback(Err( + Error::::RootNetworkDoesNotExist.into() + )); + } + }; + + if let Err(err) = Self::transfer_tao_from_subnet( + netuid, + &root_subnet_account_id, + owed_tao.amount_paid_out.into(), + ) { + log::error!("Error transferring liquidated basket TAO from subnet: {err:?}"); + return TransactionOutcome::Rollback(Err(err)); + } + + Self::record_protocol_outflow(netuid, owed_tao.amount_paid_out); + + // Credit the validator's root nominators proportionally to their root stake. + Self::increase_stake_for_hotkey_on_subnet( + hotkey, + NetUid::ROOT, + owed_tao.amount_paid_out.to_u64().into(), + ); + SubnetTAO::::mutate(NetUid::ROOT, |total| { + *total = total.saturating_add(owed_tao.amount_paid_out.into()); + }); + SubnetAlphaOut::::mutate(NetUid::ROOT, |total| { + *total = total.saturating_add(u64::from(owed_tao.amount_paid_out).into()); + }); + TotalStake::::mutate(|total| { + *total = total.saturating_add(owed_tao.amount_paid_out.into()); + }); + + TransactionOutcome::Commit(Ok::<(), DispatchError>(())) + }); + } + /// Claim all root dividends for subnet and remove all associated data. pub fn finalize_all_subnet_root_dividends(netuid: NetUid) { let hotkeys = RootClaimable::::iter_keys().collect::>(); + let escrow = Self::get_beta_escrow_account_id(); for hotkey in hotkeys.iter() { + // Liquidate the validator's beta basket on this subnet back to root stakers before + // clearing rates, so subnet teardown does not orphan basket value in the escrow. + Self::liquidate_basket_to_root_stakers(hotkey, &escrow, netuid); + BasketPrincipal::::remove(hotkey, netuid); + RootClaimable::::mutate(hotkey, |claimable| { claimable.remove(&netuid); }); @@ -464,4 +711,107 @@ impl Pallet { let _ = RootClaimed::::clear_prefix((netuid,), u32::MAX, None); } + + // ========================================================================= + // Beta basket: read-only views (for RPC / dashboards) + // ========================================================================= + + /// Mark-to-market TAO value of `alpha` on `netuid` at the current pool price. + /// This is a *marked* value (price x amount); actual redemption realizes slightly less + /// due to AMM slippage. + pub fn alpha_to_tao_value(netuid: NetUid, alpha: u64) -> u64 { + if alpha == 0 { + return 0; + } + let price = + U96F32::saturating_from_num(T::SwapInterface::current_alpha_price(netuid.into())); + U96F32::saturating_from_num(alpha) + .saturating_mul(price) + .saturating_to_num::() + } + + /// Single source of truth for the basket growth multiplier: scales an owed principal by + /// `E/P` (escrow value over outstanding principal), capped at the live escrow value so a + /// claim can never draw more than the escrow holds. + pub fn basket_payout_from(owed_principal: u64, escrow_value: u64, principal_total: u64) -> u64 { + if owed_principal == 0 || principal_total == 0 || escrow_value == 0 { + return 0; + } + U96F32::saturating_from_num(owed_principal) + .saturating_mul(U96F32::saturating_from_num(escrow_value)) + .checked_div(U96F32::saturating_from_num(principal_total)) + .unwrap_or(U96F32::saturating_from_num(0)) + .saturating_to_num::() + .min(escrow_value) + } + + /// Current basket payout (in alpha) a staker would receive on `netuid` for a validator: + /// owed principal scaled by the live `E/P` growth multiplier. Capped at the escrow value. + pub fn get_basket_payout_alpha( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + netuid: NetUid, + ) -> u64 { + let owed_principal = Self::get_root_owed_for_hotkey_coldkey(hotkey, coldkey, netuid); + let escrow = Self::get_beta_escrow_account_id(); + let escrow_value = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, netuid).to_u64(); + let principal_total = BasketPrincipal::::get(hotkey, netuid).to_u64(); + Self::basket_payout_from(owed_principal, escrow_value, principal_total) + } + + /// Total TAO a coldkey would realize by redeeming every beta basket it holds across all of + /// its validators (mark-to-market). This is the "pending TAO owed" figure for a staker. + pub fn get_root_basket_owed_tao(coldkey: &T::AccountId) -> TaoBalance { + let mut total: u64 = 0; + for hotkey in StakingHotkeys::::get(coldkey) { + for (netuid, _principal) in BasketPrincipal::::iter_prefix(&hotkey) { + let payout = Self::get_basket_payout_alpha(&hotkey, coldkey, netuid); + total = total.saturating_add(Self::alpha_to_tao_value(netuid, payout)); + } + } + total.into() + } + + /// A validator's beta basket net asset value, in TAO (mark-to-market). This is the total + /// "assets under management" backing all of the validator's stakers' baskets. + pub fn get_validator_basket_nav_tao(hotkey: &T::AccountId) -> TaoBalance { + let escrow = Self::get_beta_escrow_account_id(); + let mut nav: u64 = 0; + for (netuid, _principal) in BasketPrincipal::::iter_prefix(hotkey) { + let escrow_value = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, netuid).to_u64(); + nav = nav.saturating_add(Self::alpha_to_tao_value(netuid, escrow_value)); + } + nav.into() + } + + /// A validator's full basket breakdown: per subnet, the alpha held and its TAO value. + pub fn get_validator_basket(hotkey: &T::AccountId) -> Vec<(NetUid, AlphaBalance, TaoBalance)> { + let escrow = Self::get_beta_escrow_account_id(); + let mut out: Vec<(NetUid, AlphaBalance, TaoBalance)> = Vec::new(); + for (netuid, _principal) in BasketPrincipal::::iter_prefix(hotkey) { + let escrow_value = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, netuid); + if escrow_value.is_zero() { + continue; + } + let tao = Self::alpha_to_tao_value(netuid, escrow_value.to_u64()); + out.push((netuid, escrow_value, tao.into())); + } + out + } + + /// Network-wide total beta basket NAV across all validators, in TAO (mark-to-market). + /// Sampling this over time yields the TAO/day flowing to root stakers. + pub fn get_root_basket_total_nav_tao() -> TaoBalance { + let escrow = Self::get_beta_escrow_account_id(); + let mut nav: u64 = 0; + for (hotkey, netuid, _principal) in BasketPrincipal::::iter() { + let escrow_value = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid).to_u64(); + nav = nav.saturating_add(Self::alpha_to_tao_value(netuid, escrow_value)); + } + nav.into() + } } diff --git a/pallets/subtensor/src/subnets/weights.rs b/pallets/subtensor/src/subnets/weights.rs index 39bcfb80b5..e674c76058 100644 --- a/pallets/subtensor/src/subnets/weights.rs +++ b/pallets/subtensor/src/subnets/weights.rs @@ -929,6 +929,88 @@ impl Pallet { Self::internal_set_weights(origin, netuid, MechId::MAIN, uids, values, version_key) } + /// Sets a root validator's beta-basket distribution vector `w` on the root subnet (netuid 0). + /// + /// Unlike normal subnet weights, the `dests` here are interpreted as *subnet netuids* and the + /// values as the proportion of the validator's root dividends to deploy into each subnet's + /// alpha basket. Stored under `Weights[NetUidStorageIndex::ROOT][uid]` and consumed by + /// `distribute_root_alpha_to_basket` during emission. + pub fn do_set_root_weights( + origin: OriginFor, + dests: Vec, + values: Vec, + version_key: u64, + ) -> dispatch::DispatchResult { + // --- 1. Signed by the root validator hotkey. + let hotkey = ensure_signed(origin)?; + log::debug!("do_set_root_weights( hotkey:{hotkey:?}, dests:{dests:?}, values:{values:?} )"); + + // --- 2. Lengths match. + ensure!( + Self::uids_match_values(&dests, &values), + Error::::WeightVecNotEqualSize + ); + + // --- 3. Caller must be a registered root validator. + ensure!( + Self::is_hotkey_registered_on_network(NetUid::ROOT, &hotkey), + Error::::HotKeyNotRegisteredInSubNet + ); + + // --- 4. Must hold enough stake to set weights. + ensure!( + Self::check_weights_min_stake(&hotkey, NetUid::ROOT), + Error::::NotEnoughStakeToSetWeights + ); + + // --- 5. Version key must be current. + ensure!( + Self::check_version_key(NetUid::ROOT, version_key), + Error::::IncorrectWeightVersionKey + ); + + // --- 6. Rate limit on the root weights index. + let neuron_uid = Self::get_uid_for_net_and_hotkey(NetUid::ROOT, &hotkey)?; + let current_block: u64 = Self::get_current_block_as_u64(); + ensure!( + Self::check_rate_limit(NetUidStorageIndex::ROOT, neuron_uid, current_block), + Error::::SettingWeightsTooFast + ); + + // --- 7. No duplicate destination subnets. + ensure!(!Self::has_duplicate_uids(&dests), Error::::DuplicateUids); + + // --- 8. Every destination must be an existing, non-root subnet. + for dest in dests.iter() { + let dest_netuid = NetUid::from(*dest); + ensure!( + !dest_netuid.is_root() && Self::if_subnet_exist(dest_netuid), + Error::::UidVecContainInvalidOne + ); + } + + // --- 9. Max-upscale the weights. + let max_upscaled_weights: Vec = vec_u16_max_upscale_to_u16(&values); + + // --- 10. Zip and store in the dedicated beta-basket weights map (keyed by hotkey, NOT + // the legacy `Weights[ROOT]` consensus map, to avoid storage aliasing). + let zipped_weights: Vec<(u16, u16)> = dests + .iter() + .copied() + .zip(max_upscaled_weights.iter().copied()) + .collect(); + RootBasketWeights::::insert(&hotkey, zipped_weights); + + // --- 11. Record activity for the rate limit. + Self::set_last_update_for_uid(NetUidStorageIndex::ROOT, neuron_uid, current_block); + + // --- 12. Emit event. + log::debug!("RootWeightsSet( uid:{neuron_uid:?} )"); + Self::deposit_event(Event::RootWeightsSet(neuron_uid)); + + Ok(()) + } + /// ---- The implementation for the extrinsic set_weights. /// /// # Args: diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs index 4c8a0af5a8..3d1e437f22 100644 --- a/pallets/subtensor/src/swap/swap_hotkey.rs +++ b/pallets/subtensor/src/swap/swap_hotkey.rs @@ -571,8 +571,17 @@ impl Pallet { .map(|(coldkey, _)| coldkey) .collect(); + // The beta escrow's basket positions are tied to the validator's root identity, not + // to per-subnet membership. Skip it here and migrate baskets atomically in the root + // branch below, so a non-root single-subnet swap never moves a basket out from under + // its (unchanged) root accounting. + let beta_escrow = Self::get_beta_escrow_account_id(); + // For each coldkey remove their stake from old_hotkey and add to new_hotkey for coldkey in unique_coldkeys { + if coldkey == beta_escrow { + continue; + } let alpha_old = Self::get_stake_for_hotkey_and_coldkey_on_subnet(old_hotkey, &coldkey, netuid); Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( @@ -622,6 +631,44 @@ impl Pallet { ); weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); } + + // Migrate the beta basket for this subnet: move the escrow position + // (old_hotkey, H, subnet) -> (new_hotkey, H, subnet) by value, and the + // outstanding basket principal. Moving both keeps the E/P multiplier intact. + let basket_alpha = Self::get_stake_for_hotkey_and_coldkey_on_subnet( + old_hotkey, + &beta_escrow, + subnet, + ); + if !basket_alpha.is_zero() { + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + old_hotkey, + &beta_escrow, + subnet, + basket_alpha, + ); + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + new_hotkey, + &beta_escrow, + subnet, + basket_alpha, + ); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + } + let basket_principal = BasketPrincipal::::take(old_hotkey, subnet); + if !basket_principal.is_zero() { + BasketPrincipal::::mutate(new_hotkey, subnet, |p| { + *p = p.saturating_add(basket_principal); + }); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); + } + } + + // Move the validator's beta basket weight vector to the new hotkey. + if RootBasketWeights::::contains_key(old_hotkey) { + let w = RootBasketWeights::::take(old_hotkey); + RootBasketWeights::::insert(new_hotkey, w); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); } // Transfer AutoParentDelegationEnabled flag from old_hotkey to new_hotkey. diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index dc7253d58c..dbb819ff90 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -1,938 +1,68 @@ #![allow(clippy::expect_used, clippy::unwrap_used)] -use crate::RootAlphaDividendsPerSubnet; use crate::tests::mock::*; use crate::{ - DefaultMinRootClaimAmount, Error, MAX_NUM_ROOT_CLAIMS, MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, - NumRootClaim, NumStakingColdkeys, PendingRootAlphaDivs, RootClaimable, RootClaimableThreshold, - StakingColdkeys, StakingColdkeysByIndex, SubnetAlphaIn, SubnetAlphaOut, SubnetMechanism, - SubnetMovingPrice, SubnetProtocolFlow, SubnetRootSellTao, SubnetTAO, SubnetTaoFlow, - SubnetVolume, SubtokenEnabled, Tempo, TotalStake, pallet, + BasketPrincipal, DefaultMinRootClaimAmount, Error, Keys, MAX_NUM_ROOT_CLAIMS, + MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, NumRootClaim, NumStakingColdkeys, RootBasketWeights, + RootClaimType, RootClaimTypeEnum, RootClaimable, RootClaimableThreshold, RootClaimed, + StakingColdkeys, StakingColdkeysByIndex, SubnetAlphaIn, SubnetMovingPrice, SubnetTAO, + SubnetworkN, Tempo, TotalStake, Uids, }; -use crate::{RootClaimType, RootClaimTypeEnum, RootClaimed}; use approx::assert_abs_diff_eq; use frame_support::dispatch::RawOrigin; use frame_support::pallet_prelude::Weight; -use frame_support::traits::{Currency, Get}; +use frame_support::traits::Get; use frame_support::{assert_err, assert_noop, assert_ok}; use sp_core::{H256, U256}; use sp_runtime::DispatchError; use std::collections::BTreeSet; use substrate_fixed::types::I96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; -use subtensor_swap_interface::SwapHandler; -#[test] -fn test_claim_root_set_claim_type() { - new_test_ext(1).execute_with(|| { - let coldkey = U256::from(1); - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); - }); -} - -#[test] -fn test_claim_root_with_drain_emissions() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let hotkey = U256::from(1002); - let coldkey = U256::from(1003); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - remove_owner_registration_stake(netuid); - - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - - let root_stake = 2_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - root_stake.into(), - ); - - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), - ); - - let old_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - ); - assert_eq!(old_validator_stake, initial_total_hotkey_alpha.into()); - - // Distribute pending root alpha - - let pending_root_alpha = 1_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - // Check new validator stake - let validator_take_percent = 0.18f64; - - let new_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - ); - let calculated_validator_stake = (pending_root_alpha as f64) * validator_take_percent - + (initial_total_hotkey_alpha as f64); - - assert_abs_diff_eq!( - u64::from(new_validator_stake), - calculated_validator_stake as u64, - epsilon = 100u64, - ); - - // Check claimable - - let claimable = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); - let calculated_rate = - (pending_root_alpha as f64) * (1f64 - validator_take_percent) / (root_stake as f64); - - assert_abs_diff_eq!( - claimable.saturating_to_num::(), - calculated_rate, - epsilon = 0.001f64, - ); - - // Claim root alpha - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - - assert_abs_diff_eq!( - new_stake, - (I96F32::from(root_stake) * claimable).saturating_to_num::(), - epsilon = 10u64, - ); - - // Check root claimed value saved - - let claimed = RootClaimed::::get((netuid, &hotkey, &coldkey)); - assert_eq!(u128::from(new_stake), claimed); - - // Distribute pending root alpha (round 2) - - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - // Check claimable (round 2) - - let claimable2 = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); - let calculated_rate = - (pending_root_alpha as f64) * (1f64 - validator_take_percent) / (root_stake as f64); - - assert_abs_diff_eq!( - claimable2.saturating_to_num::(), - calculated_rate + claimable.saturating_to_num::(), - epsilon = 0.001f64, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - let new_stake2: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - let calculated_new_stake2 = - (I96F32::from(root_stake) * claimable2).saturating_to_num::(); - - assert_abs_diff_eq!( - u64::from(new_stake2), - calculated_new_stake2, - epsilon = 10u64, - ); - - // Check root claimed value saved (round 2) - - let claimed = RootClaimed::::get((netuid, &hotkey, &coldkey)); - assert_eq!(u128::from(u64::from(new_stake2)), claimed); - }); -} - -#[test] -fn test_claim_root_adding_stake_proportionally_for_two_stakers() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let other_coldkey = U256::from(10010); - let hotkey = U256::from(1002); - let alice_coldkey = U256::from(1003); - let bob_coldkey = U256::from(1004); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - - let root_stake = 1_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - NetUid::ROOT, - root_stake.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - NetUid::ROOT, - root_stake.into(), - ); - - let root_stake_rate = 0.1f64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &other_coldkey, - NetUid::ROOT, - (8 * root_stake).into(), - ); - - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), - ); - - // Claim root alpha - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(alice_coldkey), - RootClaimTypeEnum::Keep - ),); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(bob_coldkey), - RootClaimTypeEnum::Keep - ),); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), - BTreeSet::from([netuid]) - )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), - BTreeSet::from([netuid]) - )); - - // Check stakes - let validator_take_percent = 0.18f64; - - let alice_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); - - let bob_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); - - let estimated_stake = - (pending_root_alpha as f64) * (1f64 - validator_take_percent) * root_stake_rate; - - assert_eq!(alice_stake, bob_stake); - - assert_abs_diff_eq!(alice_stake, estimated_stake as u64, epsilon = 100u64,); - }); -} - -#[test] -fn test_claim_root_adding_stake_disproportionally_for_two_stakers() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let other_coldkey = U256::from(10010); - let hotkey = U256::from(1002); - let alice_coldkey = U256::from(1003); - let bob_coldkey = U256::from(1004); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - - let alice_root_stake = 1_000_000u64; - let bob_root_stake = 2_000_000u64; - let other_root_stake = 7_000_000u64; - - let alice_root_stake_rate = 0.1f64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - NetUid::ROOT, - alice_root_stake.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - NetUid::ROOT, - bob_root_stake.into(), - ); - - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &other_coldkey, - NetUid::ROOT, - (other_root_stake).into(), - ); - - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), - ); - - // Claim root alpha - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(alice_coldkey), - RootClaimTypeEnum::Keep - ),); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(bob_coldkey), - RootClaimTypeEnum::Keep - ),); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), - BTreeSet::from([netuid]) - )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), - BTreeSet::from([netuid]) - )); - - // Check stakes - let validator_take_percent = 0.18f64; - - let alice_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); - - let bob_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); - - let alice_estimated_stake = - (pending_root_alpha as f64) * (1f64 - validator_take_percent) * alice_root_stake_rate; - - assert_eq!(2 * alice_stake, bob_stake); - - assert_abs_diff_eq!(alice_stake, alice_estimated_stake as u64, epsilon = 100u64,); - }); -} - -#[test] -fn test_claim_root_with_changed_stake() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let hotkey = U256::from(1002); - let alice_coldkey = U256::from(1003); - let bob_coldkey = U256::from(1004); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - SubtokenEnabled::::insert(NetUid::ROOT, true); - NetworksAdded::::insert(NetUid::ROOT, true); - - let root_stake = 8_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - NetUid::ROOT, - root_stake.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - NetUid::ROOT, - root_stake.into(), - ); - - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), - ); - - // Claim root alpha - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(alice_coldkey), - RootClaimTypeEnum::Keep - ),); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(bob_coldkey), - RootClaimTypeEnum::Keep - ),); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), - BTreeSet::from([netuid]) - )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), - BTreeSet::from([netuid]) - )); - - // Check stakes - let validator_take_percent = 0.18f64; - - let alice_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); - - let bob_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); - - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; - - assert_eq!(alice_stake, bob_stake); - - assert_abs_diff_eq!(alice_stake, estimated_stake as u64, epsilon = 100u64,); - - // Remove stake - let stake_decrement = root_stake / 2u64; - - assert_ok!(SubtensorModule::remove_stake( - RuntimeOrigin::signed(bob_coldkey,), - hotkey, - NetUid::ROOT, - stake_decrement.into(), - )); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), - BTreeSet::from([netuid]) - )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stakes - - let alice_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); - - let bob_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); - - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 3f64; - - let alice_stake_diff = alice_stake2 - alice_stake; - let bob_stake_diff = bob_stake2 - bob_stake; - - assert_abs_diff_eq!(alice_stake_diff, 2 * bob_stake_diff, epsilon = 100u64,); - assert_abs_diff_eq!(bob_stake_diff, estimated_stake as u64, epsilon = 100u64,); - - // Add stake - let stake_increment = root_stake / 2u64; - - assert_ok!(SubtensorModule::add_stake( - RuntimeOrigin::signed(bob_coldkey,), - hotkey, - NetUid::ROOT, - stake_increment.into(), - )); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), - BTreeSet::from([netuid]) - )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stakes - - let alice_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); - - let bob_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); - - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; - - let alice_stake_diff2 = alice_stake3 - alice_stake2; - let bob_stake_diff2 = bob_stake3 - bob_stake2; - - assert_abs_diff_eq!(alice_stake_diff2, bob_stake_diff2, epsilon = 100u64,); - assert_abs_diff_eq!(bob_stake_diff2, estimated_stake as u64, epsilon = 100u64,); - }); -} - -#[test] -fn test_claim_root_with_drain_emissions_and_swap_claim_type() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let other_coldkey = U256::from(10010); - let hotkey = U256::from(1002); - let coldkey = U256::from(1003); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - SubnetMechanism::::insert(netuid, 1); - - let tao_reserve = TaoBalance::from(50_000_000_000_u64); - let alpha_in = AlphaBalance::from(100_000_000_000_u64); - SubnetTAO::::insert(netuid, tao_reserve); - SubnetAlphaIn::::insert(netuid, alpha_in); - let current_price = - ::SwapInterface::current_alpha_price(netuid.into()) - .saturating_to_num::(); - assert_eq!(current_price, 0.5f64); - - let root_stake = 2_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - root_stake.into(), - ); - let root_stake_rate = 0.1f64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &other_coldkey, - NetUid::ROOT, - (9 * root_stake).into(), - ); - - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), - ); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - // Claim root alpha - - let validator_take_percent = 0.18f64; - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Swap - ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Swap); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stake - - let new_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - ) - .into(); - - let estimated_stake_increment = (pending_root_alpha as f64) - * (1f64 - validator_take_percent) - * current_price - * root_stake_rate; - - assert_abs_diff_eq!( - new_stake, - root_stake + estimated_stake_increment as u64, - epsilon = 10000u64, - ); - - // Distribute and claim pending root alpha (round 2) - - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stake (2) - - let new_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - ) - .into(); - - // new root stake / new total stake - let root_stake_rate2 = (root_stake as f64 + estimated_stake_increment) - / (root_stake as f64 / root_stake_rate + estimated_stake_increment); - let estimated_stake_increment2 = (pending_root_alpha as f64) - * (1f64 - validator_take_percent) - * current_price - * root_stake_rate2; - - assert_abs_diff_eq!( - new_stake2, - new_stake + estimated_stake_increment2 as u64, - epsilon = 10000u64, - ); - // Distribute and claim pending root alpha (round 3) - - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stake (3) - - let new_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - ) - .into(); - - // new root stake / new total stake - let root_stake_rate3 = - (root_stake as f64 + estimated_stake_increment + estimated_stake_increment2) - / (root_stake as f64 / root_stake_rate - + estimated_stake_increment - + estimated_stake_increment2); - let estimated_stake_increment3 = (pending_root_alpha as f64) - * (1f64 - validator_take_percent) - * current_price - * root_stake_rate3; - - assert_abs_diff_eq!( - new_stake3, - new_stake2 + estimated_stake_increment3 as u64, - epsilon = 10000u64, - ); - }); -} - -/// cargo test --package pallet-subtensor --lib -- tests::claim_root::test_claim_root_with_run_coinbase --exact --nocapture -#[test] -fn test_claim_root_swap_failure_does_not_consume_claim() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let other_coldkey = U256::from(10010); - let hotkey = U256::from(1002); - let coldkey = U256::from(1003); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - - SubtensorModule::set_tao_weight(u64::MAX); - SubnetTAO::::insert(netuid, TaoBalance::from(50_000_000_000_u64)); - SubnetAlphaIn::::insert(netuid, AlphaBalance::from(100_000_000_000_u64)); - - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - 2_000_000_u64.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &other_coldkey, - NetUid::ROOT, - 18_000_000_u64.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - 10_000_000_u64.into(), - ); - - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - 10_000_000_u64.into(), - AlphaBalance::ZERO, - ); - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Swap - )); +// ============================================================================= +// Helpers +// ============================================================================= - let subnet_account = SubtensorModule::get_subnet_account_id(netuid).unwrap(); - Balances::make_free_balance_be(&subnet_account, 0.into()); +/// Directly assign a root UID and a beta-basket weight vector `w` to a validator hotkey, +/// bypassing the `set_root_weights` extrinsic's validation (which is exercised separately). +/// `dests` are `(subnet, weight)` pairs. +fn set_root_weights_direct(hotkey: &U256, _uid: u16, dests: &[(NetUid, u16)]) { + let zipped: Vec<(u16, u16)> = dests.iter().map(|(n, w)| (u16::from(*n), *w)).collect(); + RootBasketWeights::::insert(hotkey, zipped); +} - let root_claimed_before = RootClaimed::::get((netuid, &hotkey, &coldkey)); - let root_stake_before = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - ); - let subnet_tao_before = SubnetTAO::::get(netuid); - let root_subnet_tao_before = SubnetTAO::::get(NetUid::ROOT); - let subnet_alpha_in_before = SubnetAlphaIn::::get(netuid); - let subnet_alpha_out_before = SubnetAlphaOut::::get(netuid); - let total_stake_before = TotalStake::::get(); - let subnet_volume_before = SubnetVolume::::get(netuid); - let root_sell_before = SubnetRootSellTao::::get(netuid); - let protocol_flow_before = SubnetProtocolFlow::::get(netuid); +/// Ensure a subnet has deep, balanced AMM reserves so basket swaps execute with negligible +/// slippage and never fail for lack of liquidity. +fn fund_pool(netuid: NetUid) { + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); +} - assert_noop!( - SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), BTreeSet::from([netuid])), - Error::::InsufficientBalance - ); +fn escrow_alpha(hotkey: &U256, netuid: NetUid) -> u64 { + let escrow = SubtensorModule::get_beta_escrow_account_id(); + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, netuid).to_u64() +} - assert_eq!( - RootClaimed::::get((netuid, &hotkey, &coldkey)), - root_claimed_before - ); - assert_eq!( - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - ), - root_stake_before - ); - assert_eq!(SubnetTAO::::get(netuid), subnet_tao_before); - assert_eq!(SubnetTAO::::get(NetUid::ROOT), root_subnet_tao_before); - assert_eq!(SubnetAlphaIn::::get(netuid), subnet_alpha_in_before); - assert_eq!(SubnetAlphaOut::::get(netuid), subnet_alpha_out_before); - assert_eq!(TotalStake::::get(), total_stake_before); - assert_eq!(SubnetVolume::::get(netuid), subnet_volume_before); - assert_eq!(SubnetRootSellTao::::get(netuid), root_sell_before); - assert_eq!( - SubnetProtocolFlow::::get(netuid), - protocol_flow_before - ); - }); +fn root_stake_of(hotkey: &U256, coldkey: &U256) -> u64 { + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT) + .to_u64() } +// ============================================================================= +// Still-valid utility tests (independent of the beta-basket accrual mechanics) +// ============================================================================= + #[test] -fn test_claim_root_with_run_coinbase() { +fn test_claim_root_set_claim_type() { new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let hotkey = U256::from(1002); - let coldkey = U256::from(1003); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - remove_owner_registration_stake(netuid); - - Tempo::::insert(netuid, 1); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - - let root_stake = 200_000_000u64; - SubnetTAO::::insert(NetUid::ROOT, TaoBalance::from(root_stake)); - - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - root_stake.into(), - ); - - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), - ); - - // Set moving price > 1.0 and price > 1.0 - // So we turn ON root sell - SubnetMovingPrice::::insert(netuid, I96F32::from_num(2)); - let tao = TaoBalance::from(10_000_000_000_000_u64); - let alpha = AlphaBalance::from(1_000_000_000_000_u64); - SubnetTAO::::insert(netuid, tao); - SubnetAlphaIn::::insert(netuid, alpha); - let current_price = - ::SwapInterface::current_alpha_price(netuid.into()) - .saturating_to_num::(); - assert_eq!(current_price, 10.0f64); - RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - - // Make sure we are root selling, so we have root alpha divs. - let root_sell_flag = SubtensorModule::get_network_root_sell_flag(&[netuid]); - assert!(root_sell_flag, "Root sell flag should be true"); - - // Distribute pending root alpha - - let initial_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - assert_eq!(initial_stake, 0u64); - - let block_emissions = SubtensorModule::mint_tao(1_000_000u64.into()); - SubtensorModule::run_coinbase(block_emissions); - - // Claim root alpha - - let initial_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - assert_eq!(initial_stake, 0u64); + let coldkey = U256::from(1); assert_ok!(SubtensorModule::set_root_claim_type( RuntimeOrigin::signed(coldkey), RootClaimTypeEnum::Keep ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - - assert!(new_stake > 0); + assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); }); } @@ -977,79 +107,6 @@ fn test_claim_root_block_hash_indices() { }); } -#[test] -fn test_claim_root_with_block_emissions() { - new_test_ext(0).execute_with(|| { - let owner_coldkey = U256::from(1001); - let hotkey = U256::from(1002); - let coldkey = U256::from(1003); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - remove_owner_registration_stake(netuid); - - Tempo::::insert(netuid, 1); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - - let root_stake = 200_000_000u64; - SubnetTAO::::insert(NetUid::ROOT, TaoBalance::from(root_stake)); - - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - root_stake.into(), - ); - SubtensorModule::maybe_add_coldkey_index(&coldkey); - - // Set moving price > 1.0 and price > 1.0 - // So we turn ON root sell - SubnetMovingPrice::::insert(netuid, I96F32::from_num(2)); - let tao = TaoBalance::from(10_000_000_000_000_u64); - let alpha = AlphaBalance::from(1_000_000_000_000_u64); - SubnetTAO::::insert(netuid, tao); - SubnetAlphaIn::::insert(netuid, alpha); - let current_price = - ::SwapInterface::current_alpha_price(netuid.into()) - .saturating_to_num::(); - assert_eq!(current_price, 10.0f64); - RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - - // Make sure we are root selling, so we have root alpha divs. - let root_sell_flag = SubtensorModule::get_network_root_sell_flag(&[netuid]); - assert!(root_sell_flag, "Root sell flag should be true"); - - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), - ); - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); - - // Distribute pending root alpha - - let initial_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - assert_eq!(initial_stake, 0u64); - - run_to_block(2); - - // Check stake after block emissions - - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - - assert!(new_stake > 0); - }); -} - #[test] fn test_populate_staking_maps() { new_test_ext(1).execute_with(|| { @@ -1084,7 +141,6 @@ fn test_populate_staking_maps() { assert_eq!(NumStakingColdkeys::::get(), 0); // Populate maps through block step - run_to_block(2); assert_eq!(NumStakingColdkeys::::get(), 2); @@ -1099,140 +155,147 @@ fn test_populate_staking_maps() { } #[test] -fn test_claim_root_coinbase_distribution() { +fn test_sudo_set_num_root_claims() { new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let hotkey = U256::from(1002); let coldkey = U256::from(1003); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - - Tempo::::insert(netuid, 1); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - let root_stake = 200_000_000u64; - let initial_tao = 200_000_000u64; - SubnetTAO::::insert(NetUid::ROOT, TaoBalance::from(initial_tao)); - - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - root_stake.into(), + assert_noop!( + SubtensorModule::sudo_set_num_root_claims(RuntimeOrigin::signed(coldkey), 50u64), + DispatchError::BadOrigin ); - let initial_total_hotkey_alpha = 10_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - initial_total_hotkey_alpha.into(), + assert_noop!( + SubtensorModule::sudo_set_num_root_claims( + RuntimeOrigin::root(), + MAX_NUM_ROOT_CLAIMS + 1, + ), + Error::::InvalidNumRootClaim ); - // Set moving price > 1.0 and price > 1.0 - // So we turn ON root sell - SubnetMovingPrice::::insert(netuid, I96F32::from_num(2)); - let tao = TaoBalance::from(100_000_000_000_u64); - let alpha = AlphaBalance::from(100_000_000_000_u64); - SubnetTAO::::insert(netuid, tao); - SubnetAlphaIn::::insert(netuid, alpha); - // let current_price = - // ::SwapInterface::current_alpha_price(netuid.into()) - // .saturating_to_num::(); - // assert_eq!(current_price, 2.0f64); - RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - - let initial_alpha_issuance = SubtensorModule::get_alpha_issuance(netuid); - let alpha_emissions: AlphaBalance = 1_000_000_000u64.into(); - - // Make sure we are root selling, so we have root alpha divs. - let root_sell_flag = SubtensorModule::get_network_root_sell_flag(&[netuid]); - assert!(root_sell_flag, "Root sell flag should be true"); + let new_value = 27u64; + assert_ok!(SubtensorModule::sudo_set_num_root_claims( + RuntimeOrigin::root(), + new_value, + ),); - // Set TAOFlow > 0 - SubnetTaoFlow::::insert(netuid, 2222_i64); + assert_eq!(NumRootClaim::::get(), new_value); + }); +} - // Check total issuance (saved to pending alpha divs) - run_to_block(2); +#[test] +fn test_claim_root_threshold() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - let alpha_issuance = SubtensorModule::get_alpha_issuance(netuid); - // We went two blocks so we should have 2x the alpha emissions assert_eq!( - initial_alpha_issuance + alpha_emissions.saturating_mul(2.into()), - alpha_issuance + RootClaimableThreshold::::get(netuid), + DefaultMinRootClaimAmount::::get() ); - let root_prop = initial_tao as f64 / (u64::from(alpha_issuance) + initial_tao) as f64; - let root_validators_share = 0.5f64; - - let expected_pending_root_alpha_divs = - u64::from(alpha_emissions) as f64 * root_prop * root_validators_share; - assert_abs_diff_eq!( - u64::from(PendingRootAlphaDivs::::get(netuid)) as f64, - expected_pending_root_alpha_divs, - epsilon = 100f64 + let threshold = 1000u64; + assert_ok!(SubtensorModule::sudo_set_root_claim_threshold( + RawOrigin::Root.into(), + netuid, + threshold + )); + assert_eq!( + RootClaimableThreshold::::get(netuid), + I96F32::from(threshold) ); - // Epoch pending alphas divs is distributed - - run_to_block(3); - - assert_eq!(u64::from(PendingRootAlphaDivs::::get(netuid)), 0u64); - - let claimable = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); + let threshold = 2000u64; + assert_ok!(SubtensorModule::sudo_set_root_claim_threshold( + RawOrigin::Signed(owner_coldkey).into(), + netuid, + threshold + )); + assert_eq!( + RootClaimableThreshold::::get(netuid), + I96F32::from(threshold) + ); - let validator_take_percent = 0.18f64; - let calculated_rate = (expected_pending_root_alpha_divs * 2f64) - * (1f64 - validator_take_percent) - / (root_stake as f64); + // Errors + assert_err!( + SubtensorModule::sudo_set_root_claim_threshold( + RawOrigin::Signed(hotkey).into(), + netuid, + threshold + ), + DispatchError::BadOrigin, + ); - assert_abs_diff_eq!( - claimable.saturating_to_num::(), - calculated_rate, - epsilon = 0.001f64, + assert_err!( + SubtensorModule::sudo_set_root_claim_threshold( + RawOrigin::Signed(owner_coldkey).into(), + netuid, + MAX_ROOT_CLAIM_THRESHOLD + 1 + ), + Error::::InvalidRootClaimThreshold, ); }); } #[test] -fn test_sudo_set_num_root_claims() { +fn test_claim_root_subnet_limits() { new_test_ext(1).execute_with(|| { let coldkey = U256::from(1003); - assert_noop!( - SubtensorModule::sudo_set_num_root_claims(RuntimeOrigin::signed(coldkey), 50u64), - DispatchError::BadOrigin + assert_err!( + SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), BTreeSet::new()), + Error::::InvalidSubnetNumber ); - assert_noop!( - SubtensorModule::sudo_set_num_root_claims( - RuntimeOrigin::root(), - MAX_NUM_ROOT_CLAIMS + 1, + assert_err!( + SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from_iter((0u16..=10u16).map(NetUid::from)) ), - Error::::InvalidNumRootClaim + Error::::InvalidSubnetNumber ); + }); +} - let new_value = 27u64; - assert_ok!(SubtensorModule::sudo_set_num_root_claims( - RuntimeOrigin::root(), - new_value, - ),); +// ============================================================================= +// Beta basket: setting weights (extrinsic validation) +// ============================================================================= + +#[test] +fn test_set_root_weights_rejects_unregistered_hotkey() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - assert_eq!(NumRootClaim::::get(), new_value); + // `hotkey` is not registered on the root subnet, so it cannot set root weights. + assert_noop!( + SubtensorModule::set_root_weights( + RuntimeOrigin::signed(hotkey), + vec![u16::from(netuid)], + vec![u16::MAX], + 0, + ), + Error::::HotKeyNotRegisteredInSubNet + ); }); } +// ============================================================================= +// Beta basket: accrual +// ============================================================================= + #[test] -fn test_claim_root_with_swap_coldkey() { +fn test_root_basket_accrues_per_weights() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); let coldkey = U256::from(1003); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); // tao_weight = 1.0 let root_stake = 2_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( @@ -1241,23 +304,18 @@ fn test_claim_root_with_swap_coldkey() { NetUid::ROOT, root_stake.into(), ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), + 10_000_000u64.into(), ); - let old_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - ); - assert_eq!(old_validator_stake, initial_total_hotkey_alpha.into()); + // Route the basket 100% back into this subnet. + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - // Distribute pending root alpha + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert_eq!(u64::from(BasketPrincipal::::get(&hotkey, netuid)), 0); let pending_root_alpha = 1_000_000u64; SubtensorModule::distribute_emission( @@ -1268,174 +326,131 @@ fn test_claim_root_with_swap_coldkey() { AlphaBalance::ZERO, ); - // Claim root alpha - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - - // Check root claimed value saved - let new_coldkey = U256::from(10030); - - assert_eq!( - u128::from(new_stake), - RootClaimed::::get((netuid, &hotkey, &coldkey)) - ); - assert_eq!( - 0u128, - RootClaimed::::get((netuid, &hotkey, &new_coldkey)) - ); - - // Swap coldkey - assert_ok!(SubtensorModule::do_swap_coldkey(&coldkey, &new_coldkey,)); - - // Check swapped keys claimed values + // Basket principal recorded, escrow holds the basket alpha, and a claimable rate exists. + assert!(u64::from(BasketPrincipal::::get(&hotkey, netuid)) > 0); + assert!(escrow_alpha(&hotkey, netuid) > 0); + assert!(RootClaimable::::get(hotkey).contains_key(&netuid)); - assert_eq!(0u128, RootClaimed::::get((netuid, &hotkey, &coldkey))); - assert_eq!( - u128::from(new_stake), - RootClaimed::::get((netuid, &hotkey, &new_coldkey,)) + // Escrow value and recorded principal should match (E/P starts at 1). + assert_abs_diff_eq!( + escrow_alpha(&hotkey, netuid), + u64::from(BasketPrincipal::::get(&hotkey, netuid)), + epsilon = 10u64, ); }); } #[test] -fn test_claim_root_with_swap_hotkey() { +fn test_root_basket_recycles_without_weights() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); let coldkey = U256::from(1003); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); - let root_stake = 2_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &coldkey, NetUid::ROOT, - root_stake.into(), + 2_000_000u64.into(), ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), - ); - - let old_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, + 10_000_000u64.into(), ); - assert_eq!(old_validator_stake, initial_total_hotkey_alpha.into()); - // Distribute pending root alpha - - let pending_root_alpha = 1_000_000u64; + // No root weights set for the validator. SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - // Claim root alpha - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); + // Without weights the root dividend is recycled: no basket, no claimable. + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert_eq!(u64::from(BasketPrincipal::::get(&hotkey, netuid)), 0); + assert!(!RootClaimable::::get(hotkey).contains_key(&netuid)); + }); +} - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); +#[test] +fn test_root_basket_routes_to_target_subnet() { + new_test_ext(1).execute_with(|| { + let owner_a = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let owner_b = U256::from(2001); + let hotkey_b = U256::from(2002); - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); + let netuid_a = add_dynamic_network(&hotkey, &owner_a); + let netuid_b = add_dynamic_network(&hotkey_b, &owner_b); + remove_owner_registration_stake(netuid_a); + fund_pool(netuid_a); + fund_pool(netuid_b); - // Check root claimed value saved - let new_hotkey = U256::from(10030); + SubtensorModule::set_tao_weight(u64::MAX); - assert_eq!( - u128::from(new_stake), - RootClaimed::::get((netuid, &hotkey, &coldkey,)) + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), ); - assert_eq!( - 0u128, - RootClaimed::::get((netuid, &new_hotkey, &coldkey,)) + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_a, + netuid_a, + 10_000_000u64.into(), ); - let _old_claimable = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); - - assert!(!RootClaimable::::get(new_hotkey).contains_key(&netuid)); - - // Swap hotkey - let mut weight = Weight::zero(); - assert_ok!(SubtensorModule::perform_hotkey_swap_on_one_subnet( - &hotkey, - &new_hotkey, - &mut weight, - netuid, - false, - )); + // Route the basket entirely into subnet B (different from the dividend origin A). + set_root_weights_direct(&hotkey, 0, &[(netuid_b, u16::MAX)]); - // Check swapped keys claimed values - assert_eq!( - u128::from(new_stake), // It shouldn't change, because we didn't swap the root hotkey - RootClaimed::::get((netuid, &hotkey, &coldkey,)) + SubtensorModule::distribute_emission( + netuid_a, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, ); + + // Basket should be on B, not A. + assert!(escrow_alpha(&hotkey, netuid_b) > 0); + assert_eq!(escrow_alpha(&hotkey, netuid_a), 0); + assert!(u64::from(BasketPrincipal::::get(&hotkey, netuid_b)) > 0); assert_eq!( - 0u128, - RootClaimed::::get((netuid, &new_hotkey, &coldkey,)) + u64::from(BasketPrincipal::::get(&hotkey, netuid_a)), + 0 ); - - assert!(RootClaimable::::get(hotkey).contains_key(&netuid)); - - assert!(!RootClaimable::::get(new_hotkey).contains_key(&netuid)); + assert!(RootClaimable::::get(hotkey).contains_key(&netuid_b)); + assert!(!RootClaimable::::get(hotkey).contains_key(&netuid_a)); }); } +// ============================================================================= +// Beta basket: claiming (always full swap to root TAO) +// ============================================================================= + #[test] -fn test_claim_root_on_network_deregistration() { +fn test_root_basket_claim_swaps_to_root() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); - let other_coldkey = U256::from(10010); let hotkey = U256::from(1002); let coldkey = U256::from(1003); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - SubnetMechanism::::insert(netuid, 1); - - let tao_reserve = TaoBalance::from(50_000_000_000_u64); - let alpha_in = AlphaBalance::from(100_000_000_000_u64); - SubnetTAO::::insert(netuid, tao_reserve); - SubnetAlphaIn::::insert(netuid, alpha_in); - let current_price = - ::SwapInterface::current_alpha_price(netuid.into()) - .saturating_to_num::(); - assert_eq!(current_price, 0.5f64); + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); let root_stake = 2_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( @@ -1444,726 +459,725 @@ fn test_claim_root_on_network_deregistration() { NetUid::ROOT, root_stake.into(), ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &other_coldkey, - NetUid::ROOT, - (9 * root_stake).into(), - ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), + 10_000_000u64.into(), ); - // Distribute pending root alpha + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - let pending_root_alpha = 10_000_000u64; SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); + let principal_before = u64::from(BasketPrincipal::::get(&hotkey, netuid)); + assert!(principal_before > 0); + let root_before = root_stake_of(&hotkey, &coldkey); + assert_eq!(root_before, root_stake); + + // Claim: full swap of the basket to TAO, staked on root. assert_ok!(SubtensorModule::claim_root( RuntimeOrigin::signed(coldkey), BTreeSet::from([netuid]) )); - assert!(RootClaimable::::get(hotkey).contains_key(&netuid)); - - assert!(RootClaimed::::contains_key(( - netuid, &hotkey, &coldkey, - ))); - - // Claim root via network deregistration - - assert_ok!(SubtensorModule::do_dissolve_network(netuid)); - - assert!(!RootClaimed::::contains_key(( - netuid, &hotkey, &coldkey, - ))); - assert!(!RootClaimable::::get(hotkey).contains_key(&netuid)); + // Staker's root stake increased, basket principal consumed, watermark advanced. + assert!(root_stake_of(&hotkey, &coldkey) > root_before); + assert!(u64::from(BasketPrincipal::::get(&hotkey, netuid)) < principal_before); + assert!(RootClaimed::::get((netuid, &hotkey, &coldkey)) > 0); }); } #[test] -fn test_claim_root_threshold() { +fn test_root_basket_proportional_two_stakers() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); + let alice = U256::from(1003); + let bob = U256::from(1004); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); - assert_eq!( - RootClaimableThreshold::::get(netuid), - DefaultMinRootClaimAmount::::get() - ); + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - let threshold = 1000u64; - assert_ok!(SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Root.into(), - netuid, - threshold - )); - assert_eq!( - RootClaimableThreshold::::get(netuid), - I96F32::from(threshold) + // Equal root stake for both stakers. + let root_stake = 1_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &alice, + NetUid::ROOT, + root_stake.into(), ); - - let threshold = 2000u64; - assert_ok!(SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Signed(owner_coldkey).into(), + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &bob, + NetUid::ROOT, + root_stake.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, netuid, - threshold - )); - assert_eq!( - RootClaimableThreshold::::get(netuid), - I96F32::from(threshold) + 10_000_000u64.into(), ); - // Errors - assert_err!( - SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Signed(hotkey).into(), - netuid, - threshold - ), - DispatchError::BadOrigin, - ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - assert_err!( - SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Signed(owner_coldkey).into(), - netuid, - MAX_ROOT_CLAIM_THRESHOLD + 1 - ), - Error::::InvalidRootClaimThreshold, + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 10_000_000u64.into(), + AlphaBalance::ZERO, ); - }); -} -#[test] -fn test_claim_root_subnet_limits() { - new_test_ext(1).execute_with(|| { - let coldkey = U256::from(1003); + let alice_before = root_stake_of(&hotkey, &alice); + let bob_before = root_stake_of(&hotkey, &bob); - assert_err!( - SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), BTreeSet::new()), - Error::::InvalidSubnetNumber - ); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(alice), + BTreeSet::from([netuid]) + )); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(bob), + BTreeSet::from([netuid]) + )); - assert_err!( - SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from_iter((0u16..=10u16).into_iter().map(NetUid::from)) - ), - Error::::InvalidSubnetNumber - ); + let alice_gain = root_stake_of(&hotkey, &alice).saturating_sub(alice_before); + let bob_gain = root_stake_of(&hotkey, &bob).saturating_sub(bob_before); + + assert!(alice_gain > 0); + // Equal root stake => equal basket payout (small AMM slippage between the two + // sequential claims on the same pool). + assert_abs_diff_eq!(alice_gain, bob_gain, epsilon = 1_000u64); }); } +// ============================================================================= +// Beta basket: hotkey swap migration +// ============================================================================= + #[test] -fn test_claim_root_with_unrelated_subnets() { +fn test_root_basket_hotkey_swap_migrates() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); let coldkey = U256::from(1003); + let new_hotkey = U256::from(10030); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); - let root_stake = 2_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &coldkey, NetUid::ROOT, - root_stake.into(), + 2_000_000u64.into(), ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), - ); - - let old_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, + 10_000_000u64.into(), ); - assert_eq!(old_validator_stake, initial_total_hotkey_alpha.into()); - // Distribute pending root alpha + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - let pending_root_alpha = 1_000_000u64; SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - // Claim root alpha + let basket_before = escrow_alpha(&hotkey, netuid); + let principal_before = u64::from(BasketPrincipal::::get(&hotkey, netuid)); + assert!(basket_before > 0); + assert!(principal_before > 0); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - - // Claim root alpha on unrelated subnets - - let unrelated_subnet_uid = NetUid::from(100u16); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([unrelated_subnet_uid]) - )); - - let new_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // Swap the validator's root hotkey: the basket must follow it. + let mut weight = Weight::zero(); + assert_ok!(SubtensorModule::perform_hotkey_swap_on_one_subnet( &hotkey, - &coldkey, - unrelated_subnet_uid, - ) - .into(); + &new_hotkey, + &mut weight, + NetUid::ROOT, + false, + )); - assert_eq!(new_stake, 0u64,); + // Basket moved to the new hotkey, old slot emptied. + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert_eq!(u64::from(BasketPrincipal::::get(&hotkey, netuid)), 0); + assert_abs_diff_eq!( + escrow_alpha(&new_hotkey, netuid), + basket_before, + epsilon = 10u64 + ); + assert_abs_diff_eq!( + u64::from(BasketPrincipal::::get(&new_hotkey, netuid)), + principal_before, + epsilon = 10u64, + ); + assert!(RootClaimable::::get(new_hotkey).contains_key(&netuid)); + assert!(!RootClaimable::::get(hotkey).contains_key(&netuid)); + }); +} - // Check root claim for correct subnet +// ============================================================================= +// Beta basket: subnet dissolution liquidates the basket back to root stakers +// ============================================================================= - // before - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); +#[test] +fn test_root_basket_dissolve_liquidates_to_stakers() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); - assert_eq!(new_stake, 0u64,); + SubtensorModule::set_tao_weight(u64::MAX); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - // after - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); - assert!(new_stake > 0u64); + assert!(escrow_alpha(&hotkey, netuid) > 0); + let root_before = root_stake_of(&hotkey, &coldkey); - // Check root claimed value saved + // Dissolving the subnet liquidates the basket back to the validator's root stakers. + assert_ok!(SubtensorModule::do_dissolve_network(netuid)); - let claimed = RootClaimed::::get((netuid, &hotkey, &coldkey)); - assert_eq!(u128::from(new_stake), claimed); + // Basket principal cleared; root stakers credited. + assert_eq!(u64::from(BasketPrincipal::::get(&hotkey, netuid)), 0); + assert!(!RootClaimable::::get(hotkey).contains_key(&netuid)); + assert!(root_stake_of(&hotkey, &coldkey) > root_before); }); } +// ============================================================================= +// Beta basket: conservation invariants ("prove it works") +// ============================================================================= + +/// TotalStake (the global TAO ledger) must be neutral across both basket distribution +/// (sell origin alpha -> rebuy across w) and redemption (swap basket -> TAO on root): +/// no TAO is minted or destroyed by the round trips. #[test] -fn test_claim_root_fill_root_alpha_dividends_per_subnet() { +fn test_root_basket_total_stake_conserved() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); - let other_coldkey = U256::from(10010); let hotkey = U256::from(1002); let coldkey = U256::from(1003); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - SubnetMechanism::::insert(netuid, 1); - - let tao_reserve = TaoBalance::from(50_000_000_000_u64); - let alpha_in = AlphaBalance::from(100_000_000_000_u64); - SubnetTAO::::insert(netuid, tao_reserve); - SubnetAlphaIn::::insert(netuid, alpha_in); + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - let root_stake = 2_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &coldkey, NetUid::ROOT, - root_stake.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &other_coldkey, - NetUid::ROOT, - (9 * root_stake).into(), + 2_000_000u64.into(), ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), + 10_000_000u64.into(), ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - // Check RootAlphaDividendsPerSubnet is empty on start - assert!(!RootAlphaDividendsPerSubnet::::contains_key( - netuid, hotkey - )); - - let pending_root_alpha = 10_000_000u64; + // --- Distribution must not move TotalStake (sell + rebuy is TAO-neutral). + let ts_before_distribute = TotalStake::::get().to_u64(); SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - - // Check RootAlphaDividendsPerSubnet value - let root_claim_dividends1 = RootAlphaDividendsPerSubnet::::get(netuid, hotkey); - - let validator_take_percent = 0.18f64; - let estimated_root_claim_dividends = - (pending_root_alpha as f64) * (1f64 - validator_take_percent); - - assert_abs_diff_eq!( - estimated_root_claim_dividends as u64, - u64::from(root_claim_dividends1), - epsilon = 100u64, + let ts_after_distribute = TotalStake::::get().to_u64(); + assert_eq!( + ts_before_distribute, ts_after_distribute, + "distribution must be TotalStake-neutral" ); - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, + // --- Redemption must also be TotalStake-neutral (swap out then stake on root). + let ts_before_claim = TotalStake::::get().to_u64(); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from([netuid]) + )); + let ts_after_claim = TotalStake::::get().to_u64(); + assert_eq!( + ts_before_claim, ts_after_claim, + "redemption must be TotalStake-neutral" ); - - let root_claim_dividends2 = RootAlphaDividendsPerSubnet::::get(netuid, hotkey); - - // Check RootAlphaDividendsPerSubnet is cleaned each epoch - assert_eq!(root_claim_dividends1, root_claim_dividends2); }); } +/// The basket compounds: if the escrow position grows (validator earns more on the subnet) +/// after accrual, a sole staker redeems MORE than their recorded principal — the `E/P` +/// multiplier carries the growth through to the staker. #[test] -fn test_claim_root_with_keep_subnets() { +fn test_root_basket_compounds_when_escrow_grows() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); let coldkey = U256::from(1003); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - let root_stake = 2_000_000u64; + // Single root staker => owns 100% of the basket. mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &coldkey, NetUid::ROOT, - root_stake.into(), + 2_000_000u64.into(), ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), + 10_000_000u64.into(), ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - let old_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - ); - assert_eq!(old_validator_stake, initial_total_hotkey_alpha.into()); - - // Distribute pending root alpha - - let pending_root_alpha = 1_000_000u64; SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - let claimable = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); + let principal = u64::from(BasketPrincipal::::get(&hotkey, netuid)); + let escrow_before = escrow_alpha(&hotkey, netuid); + assert!(principal > 0); - // Claim root alpha - assert_err!( - SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::KeepSubnets { - subnets: BTreeSet::new() - }, - ), - Error::::InvalidSubnetNumber + // Validator earns more nominator dividends on the subnet => escrow value grows, + // principal stays fixed (E/P rises above 1). + SubtensorModule::increase_stake_for_hotkey_on_subnet( + &hotkey, + netuid, + 100_000_000u64.into(), + ); + let escrow_after = escrow_alpha(&hotkey, netuid); + assert!( + escrow_after > escrow_before, + "escrow must grow with dividends" ); - let keep_subnets = RootClaimTypeEnum::KeepSubnets { - subnets: BTreeSet::from([netuid]), - }; - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - keep_subnets.clone(), - ),); - assert_eq!(RootClaimType::::get(coldkey), keep_subnets); - + let root_before = root_stake_of(&hotkey, &coldkey); assert_ok!(SubtensorModule::claim_root( RuntimeOrigin::signed(coldkey), BTreeSet::from([netuid]) )); + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - - assert_abs_diff_eq!( - new_stake, - (I96F32::from(root_stake) * claimable).saturating_to_num::(), - epsilon = 10u64, + // The sole staker realizes the *grown* basket, strictly more than principal. + assert!( + gain > principal, + "compounding: realized {gain} must exceed principal {principal}" ); }); } +/// Claiming drains the basket exactly: after all stakers redeem, the escrow position and the +/// outstanding basket principal both go to ~zero (Σ payouts == escrow value; no residual, +/// no over-draw). #[test] -fn test_claim_root_keep_subnets_swap_claim_type() { +fn test_root_basket_fully_drains_on_claims() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); - let other_coldkey = U256::from(10010); let hotkey = U256::from(1002); - let coldkey = U256::from(1003); + let alice = U256::from(1003); + let bob = U256::from(1004); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - SubnetMechanism::::insert(netuid, 1); - - let tao_reserve = TaoBalance::from(50_000_000_000_u64); - let alpha_in = AlphaBalance::from(100_000_000_000_u64); - SubnetTAO::::insert(netuid, tao_reserve); - SubnetAlphaIn::::insert(netuid, alpha_in); - let current_price = - ::SwapInterface::current_alpha_price(netuid.into()) - .saturating_to_num::(); - assert_eq!(current_price, 0.5f64); + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - let root_stake = 2_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &coldkey, + &alice, NetUid::ROOT, - root_stake.into(), + 1_000_000u64.into(), ); - let root_stake_rate = 0.1f64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &other_coldkey, + &bob, NetUid::ROOT, - (9 * root_stake).into(), + 3_000_000u64.into(), ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), + 10_000_000u64.into(), ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 10_000_000u64.into(), AlphaBalance::ZERO, ); - // Claim root alpha - - let validator_take_percent = 0.18f64; - // Set to keep 'another' subnet - let keep_subnets = RootClaimTypeEnum::KeepSubnets { - subnets: BTreeSet::from([NetUid::from(100u16)]), - }; - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - keep_subnets.clone() - ),); - assert_eq!(RootClaimType::::get(coldkey), keep_subnets); + let escrow_filled = escrow_alpha(&hotkey, netuid); + assert!(escrow_filled > 0); assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), + RuntimeOrigin::signed(alice), + BTreeSet::from([netuid]) + )); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(bob), BTreeSet::from([netuid]) )); - // Check new stake - - let new_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey, - NetUid::ROOT, - ) - .into(); - - let estimated_stake_increment = (pending_root_alpha as f64) - * (1f64 - validator_take_percent) - * current_price - * root_stake_rate; - - assert_abs_diff_eq!( - new_stake, - root_stake + estimated_stake_increment as u64, - epsilon = 10000u64, + // Escrow and principal fully drained (allow tiny rounding dust). + assert!( + escrow_alpha(&hotkey, netuid) <= 10, + "escrow must be drained, got {}", + escrow_alpha(&hotkey, netuid) + ); + assert!( + u64::from(BasketPrincipal::::get(&hotkey, netuid)) <= 10, + "principal must be drained, got {}", + u64::from(BasketPrincipal::::get(&hotkey, netuid)) ); }); } +/// Disproportionate root stake yields proportionate payout: a staker with 2x the root stake +/// redeems ~2x the TAO. #[test] -fn test_claim_root_default_mode_keep() { - new_test_ext(1).execute_with(|| { - let coldkey = U256::from(1003); - - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Swap); - }); -} - -#[test] -fn test_claim_root_with_moved_stake() { +fn test_root_basket_disproportional_two_stakers() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); - let alice_coldkey = U256::from(1003); - let bob_coldkey = U256::from(1004); - let eve_coldkey = U256::from(1005); + let alice = U256::from(1003); + let bob = U256::from(1004); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 - SubtokenEnabled::::insert(NetUid::ROOT, true); - NetworksAdded::::insert(NetUid::ROOT, true); + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - let root_stake = 8_000_000u64; + // Bob has 2x Alice's root stake. mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &alice_coldkey, + &alice, NetUid::ROOT, - root_stake.into(), + 1_000_000u64.into(), ); mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &bob_coldkey, + &bob, NetUid::ROOT, - root_stake.into(), + 2_000_000u64.into(), ); - - let initial_total_hotkey_alpha = 10_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - initial_total_hotkey_alpha.into(), + 10_000_000u64.into(), ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - // Claim root alpha - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(alice_coldkey), - RootClaimTypeEnum::Keep - ),); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(bob_coldkey), - RootClaimTypeEnum::Keep - ),); - - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(eve_coldkey), - RootClaimTypeEnum::Keep - ),); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 10_000_000u64.into(), AlphaBalance::ZERO, ); + let alice_before = root_stake_of(&hotkey, &alice); + let bob_before = root_stake_of(&hotkey, &bob); + assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), + RuntimeOrigin::signed(alice), BTreeSet::from([netuid]) )); assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), + RuntimeOrigin::signed(bob), BTreeSet::from([netuid]) )); - // Check stakes - let validator_take_percent = 0.18f64; - - let alice_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); + let alice_gain = root_stake_of(&hotkey, &alice).saturating_sub(alice_before); + let bob_gain = root_stake_of(&hotkey, &bob).saturating_sub(bob_before); - let bob_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); + assert!(alice_gain > 0); + // Bob staked 2x => ~2x payout (small AMM slippage between sequential claims). + assert_abs_diff_eq!(bob_gain, 2 * alice_gain, epsilon = 2_000u64); + }); +} - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; +/// A weight vector that spans multiple subnets splits the basket across them in proportion +/// to the weights. +#[test] +fn test_root_basket_splits_across_multiple_subnets() { + new_test_ext(1).execute_with(|| { + let owner_a = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let owner_b = U256::from(2001); + let hotkey_b = U256::from(2002); + let owner_c = U256::from(3001); + let hotkey_c = U256::from(3002); + + let netuid_a = add_dynamic_network(&hotkey, &owner_a); + let netuid_b = add_dynamic_network(&hotkey_b, &owner_b); + let netuid_c = add_dynamic_network(&hotkey_c, &owner_c); + remove_owner_registration_stake(netuid_a); + fund_pool(netuid_a); + fund_pool(netuid_b); + fund_pool(netuid_c); - assert_eq!(alice_stake, bob_stake); + SubtensorModule::set_tao_weight(u64::MAX); - assert_abs_diff_eq!(alice_stake, estimated_stake as u64, epsilon = 100u64,); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_a, + netuid_a, + 10_000_000u64.into(), + ); - // Distribute pending root alpha + // 50/50 split between B and C (neither is the origin A). + set_root_weights_direct(&hotkey, 0, &[(netuid_b, u16::MAX), (netuid_c, u16::MAX)]); - let pending_root_alpha = 10_000_000u64; SubtensorModule::distribute_emission( - netuid, + netuid_a, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 10_000_000u64.into(), AlphaBalance::ZERO, ); - // Transfer stake to other coldkey - let stake_decrement = root_stake / 2u64; + let basket_b = escrow_alpha(&hotkey, netuid_b); + let basket_c = escrow_alpha(&hotkey, netuid_c); - assert_ok!(SubtensorModule::transfer_stake( - RuntimeOrigin::signed(bob_coldkey,), - eve_coldkey, - hotkey, - NetUid::ROOT, - NetUid::ROOT, - stake_decrement.into(), - )); + assert!(basket_b > 0 && basket_c > 0, "both targets must be funded"); + assert_eq!( + escrow_alpha(&hotkey, netuid_a), + 0, + "origin must hold nothing" + ); + // Equal weights + equal-depth pools => ~equal split. + assert_abs_diff_eq!(basket_b, basket_c, epsilon = 1_000u64); + }); +} + +/// The `set_root_weights` extrinsic stores the validator's vector under the root weights index. +#[test] +fn test_set_root_weights_stores_vector() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - let eve_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // Register the validator on root (uid 0) and give it stake. + NetworksAdded::::insert(NetUid::ROOT, true); + SubnetworkN::::insert(NetUid::ROOT, 1); + Uids::::insert(NetUid::ROOT, hotkey, 0u16); + Keys::::insert(NetUid::ROOT, 0u16, hotkey); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &eve_coldkey, - netuid, - ) - .into(); + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), - BTreeSet::from([netuid]) - )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), - BTreeSet::from([netuid]) + assert_ok!(SubtensorModule::set_root_weights( + RuntimeOrigin::signed(hotkey), + vec![u16::from(netuid)], + vec![u16::MAX], + 0, )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(eve_coldkey), - BTreeSet::from([netuid]) - )); + let stored = RootBasketWeights::::get(hotkey); + assert_eq!(stored, vec![(u16::from(netuid), u16::MAX)]); + }); +} - // Check new stakes +/// The read-only views (RPC surface) report the basket correctly: a sole staker's "owed TAO" +/// equals the validator NAV equals the network total, and the breakdown lists the slot. +#[test] +fn test_root_basket_rpc_views() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); // price ~= 1.0 (TAO reserve == alpha reserve) - let alice_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); - let bob_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); + // Empty baskets read as zero everywhere. + assert_eq!(SubtensorModule::get_root_basket_total_nav_tao().to_u64(), 0); + assert_eq!( + SubtensorModule::get_validator_basket_nav_tao(&hotkey).to_u64(), + 0 + ); - let eve_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // Single staker => owns 100% of the basket. + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &eve_coldkey, - netuid, - ) - .into(); - - // Eve should not have gotten any root claim - let eve_stake_diff = eve_stake2 - eve_stake; - assert_abs_diff_eq!(eve_stake_diff, 0, epsilon = 100u64,); - - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; - - let alice_stake_diff = alice_stake2 - alice_stake; - let bob_stake_diff = bob_stake2 - bob_stake; - - assert_abs_diff_eq!(alice_stake_diff, bob_stake_diff, epsilon = 100u64,); - assert_abs_diff_eq!(bob_stake_diff, estimated_stake as u64, epsilon = 100u64,); - - // Transfer stake back - let stake_increment = stake_decrement; - - assert_ok!(SubtensorModule::transfer_stake( - RuntimeOrigin::signed(eve_coldkey,), - bob_coldkey, - hotkey, - NetUid::ROOT, + &coldkey, NetUid::ROOT, - stake_increment.into(), - )); - - // Distribute pending root alpha + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - let pending_root_alpha = 10_000_000u64; SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(alice_coldkey), - BTreeSet::from([netuid]) - )); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(bob_coldkey), - BTreeSet::from([netuid]) - )); + let nav = SubtensorModule::get_validator_basket_nav_tao(&hotkey).to_u64(); + let total = SubtensorModule::get_root_basket_total_nav_tao().to_u64(); + let owed = SubtensorModule::get_root_basket_owed_tao(&coldkey).to_u64(); + let basket = SubtensorModule::get_validator_basket(&hotkey); + + assert!(nav > 0, "validator NAV must be positive"); + // Single validator => network total == this validator's NAV. + assert_eq!(total, nav); + // Sole staker => owed (marked) == NAV (marked), both value the same escrow alpha. + assert_abs_diff_eq!(owed, nav, epsilon = 10u64); + + // Breakdown lists exactly the one funded subnet, and its TAO value sums to the NAV. + assert_eq!(basket.len(), 1); + assert_eq!(basket[0].0, netuid); + assert!(basket[0].1.to_u64() > 0); // alpha held + assert_eq!(basket[0].2.to_u64(), nav); // tao value == NAV + }); +} + +/// End-to-end through the real coinbase path (block_step -> run_coinbase -> emit_to_subnets +/// -> drain_pending -> distribute_emission), proving the basket forms from actual block +/// emission rather than a direct `distribute_emission` call. +#[test] +fn test_root_basket_end_to_end_via_coinbase() { + new_test_ext(0).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); - // Check new stakes + Tempo::::insert(netuid, 1); + SubtensorModule::set_tao_weight(u64::MAX); - let alice_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + let root_stake = 200_000_000u64; + SubnetTAO::::insert(NetUid::ROOT, TaoBalance::from(root_stake)); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &alice_coldkey, - netuid, - ) - .into(); + &coldkey, + NetUid::ROOT, + root_stake.into(), + ); - let bob_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // Turn root-sell ON: moving price + spot price > 1. + SubnetMovingPrice::::insert(netuid, I96F32::from_num(2)); + SubnetTAO::::insert(netuid, TaoBalance::from(10_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); + assert!( + SubtensorModule::get_network_root_sell_flag(&[netuid]), + "root sell flag must be ON" + ); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &bob_coldkey, + &owner_coldkey, netuid, - ) - .into(); + 10_000_000u64.into(), + ); + + // Validator routes its basket back into the subnet. + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; + assert_eq!(escrow_alpha(&hotkey, netuid), 0); - let alice_stake_diff2 = alice_stake3 - alice_stake2; - let bob_stake_diff2 = bob_stake3 - bob_stake2; + // Run real blocks: emission accrues and drains through the coinbase. + run_to_block(3); + + // The basket formed end-to-end from actual block emission. + assert!( + escrow_alpha(&hotkey, netuid) > 0, + "basket must form from coinbase emission" + ); + assert!(u64::from(BasketPrincipal::::get(&hotkey, netuid)) > 0); + assert!(RootClaimable::::get(hotkey).contains_key(&netuid)); - assert_abs_diff_eq!(alice_stake_diff2, bob_stake_diff2, epsilon = 100u64,); - assert_abs_diff_eq!(bob_stake_diff2, estimated_stake as u64, epsilon = 100u64,); + // And it is redeemable to root TAO. + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from([netuid]) + )); + assert!(root_stake_of(&hotkey, &coldkey) > root_before); }); } diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 73cdacac7e..f2c8549647 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -4818,3 +4818,64 @@ fn test_migrate_reset_tnet_conviction_locks() { ); }); } + +// SKIP_WASM_BUILD=1 cargo test --package pallet-subtensor --lib -- tests::migration::test_migrate_seed_beta_basket --exact --nocapture +#[test] +fn test_migrate_seed_beta_basket() { + use crate::migrations::migrate_seed_beta_basket::migrate_seed_beta_basket; + + new_test_ext(1).execute_with(|| { + const MIGRATION_NAME: &[u8] = b"migrate_seed_beta_basket"; + + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + + // Validator has root stake; a legacy claimable rate exists on `netuid` with no claims yet. + let root_stake = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + root_stake.into(), + ); + + // rate = 0.5 alpha-principal per unit root stake => gross = 1_000_000 alpha. + let rate = I96F32::from_num(0.5); + RootClaimable::::mutate(hotkey, |m| { + m.insert(netuid, rate); + }); + + assert_eq!(u64::from(BasketPrincipal::::get(&hotkey, netuid)), 0); + let escrow = SubtensorModule::get_beta_escrow_account_id(); + assert_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid) + .to_u64(), + 0 + ); + + let w = migrate_seed_beta_basket::(); + assert!(!w.is_zero()); + assert!(HasMigrationRun::::get(MIGRATION_NAME.to_vec())); + + // remaining = rate * total_root - claimed = 0.5 * 2_000_000 - 0 = 1_000_000. + let expected = 1_000_000u64; + assert_abs_diff_eq!( + u64::from(BasketPrincipal::::get(&hotkey, netuid)), + expected, + epsilon = 10u64, + ); + // Escrow now holds the basket alpha (E == P, so E/P = 1). + assert_abs_diff_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid) + .to_u64(), + expected, + epsilon = 10u64, + ); + + // Idempotent: a second run is a no-op (only reads the flag). + let w2 = migrate_seed_beta_basket::(); + assert_eq!(w2, ::DbWeight::get().reads(1)); + }); +} diff --git a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs index 426572bdcd..9da309fe8e 100644 --- a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs +++ b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs @@ -2467,6 +2467,11 @@ fn test_revert_claim_root_with_swap_hotkey() { initial_total_hotkey_alpha.into(), ); + // Route the validator's beta basket back into this subnet so dividends accrue. + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); + RootBasketWeights::::insert(hk1, vec![(u16::from(netuid), u16::MAX)]); + let pending_root_alpha = 1_000_000u64; SubtensorModule::distribute_emission( netuid, @@ -2485,14 +2490,12 @@ fn test_revert_claim_root_with_swap_hotkey() { BTreeSet::from([netuid]) )); - let stake_after_claim: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hk1, &coldkey, netuid) - .into(); - let hk1_root_claimed = RootClaimed::::get((netuid, &hk1, &coldkey)); let hk1_claimable = *RootClaimable::::get(hk1).get(&netuid).unwrap(); - assert_eq!(u128::from(stake_after_claim), hk1_root_claimed); + // Claiming now swaps the basket to TAO on root (not subnet alpha), so we only assert the + // watermark advanced; the rest of the test verifies claim state transfer/revert on swap. + assert!(hk1_root_claimed > 0); assert!(!RootClaimable::::get(hk2).contains_key(&netuid)); System::set_block_number(System::block_number() + HotkeySwapOnSubnetInterval::get()); @@ -2953,6 +2956,11 @@ fn test_swap_hotkey_root_claims_unchanged_if_not_root() { ); assert_eq!(validator_stake, initial_total_hotkey_alpha.into()); + // Route the validator's beta basket back into this subnet so dividends accrue. + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); + RootBasketWeights::::insert(neuron_hotkey, vec![(u16::from(netuid), u16::MAX)]); + // Distribute pending root alpha let pending_root_alpha = 1_000_000_000u64; SubtensorModule::distribute_emission( @@ -3032,6 +3040,11 @@ fn test_swap_hotkey_root_claims_changed_if_root() { initial_total_hotkey_alpha.into(), ); + // Route the validator's beta basket back into this subnet so dividends accrue. + SubnetTAO::::insert(netuid_1, TaoBalance::from(1_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid_1, AlphaBalance::from(1_000_000_000_000u64)); + RootBasketWeights::::insert(neuron_hotkey, vec![(u16::from(netuid_1), u16::MAX)]); + // Distribute pending root alpha let pending_root_alpha = 1_000_000_000u64; SubtensorModule::distribute_emission( @@ -3121,6 +3134,11 @@ fn test_swap_hotkey_root_claims_changed_if_all_subnets() { initial_total_hotkey_alpha.into(), ); + // Route the validator's beta basket back into this subnet so dividends accrue. + SubnetTAO::::insert(netuid_1, TaoBalance::from(1_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid_1, AlphaBalance::from(1_000_000_000_000u64)); + RootBasketWeights::::insert(neuron_hotkey, vec![(u16::from(netuid_1), u16::MAX)]); + // Distribute pending root alpha let pending_root_alpha = 1_000_000_000u64; SubtensorModule::distribute_emission( diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 15607d1e09..3a74829ebb 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -2579,6 +2579,21 @@ impl_runtime_apis! { } } + impl subtensor_custom_rpc_runtime_api::BetaBasketRuntimeApi for Runtime { + fn get_root_basket_owed(coldkey: AccountId32) -> TaoBalance { + SubtensorModule::get_root_basket_owed_tao(&coldkey) + } + fn get_validator_basket_nav(hotkey: AccountId32) -> TaoBalance { + SubtensorModule::get_validator_basket_nav_tao(&hotkey) + } + fn get_validator_basket(hotkey: AccountId32) -> Vec<(NetUid, AlphaBalance, TaoBalance)> { + SubtensorModule::get_validator_basket(&hotkey) + } + fn get_root_basket_total_nav() -> TaoBalance { + SubtensorModule::get_root_basket_total_nav_tao() + } + } + impl subtensor_custom_rpc_runtime_api::ProxyFilterRuntimeApi for Runtime { fn get_proxy_types() -> Vec { get_all_proxy_type_infos() From 2aade21f6159c4edc66bb03bb7a791b02e003331 Mon Sep 17 00:00:00 2001 From: unconst Date: Mon, 15 Jun 2026 17:26:40 -0600 Subject: [PATCH 2/8] Reuse Weights[ROOT] for basket vector instead of dedicated storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revive the existing root-weights plumbing: store the basket vector under Weights[ROOT][uid] (uid-keyed, so it follows the validator through hotkey swaps automatically and reuses existing weight terms/limits) rather than a separate RootBasketWeights map. Keep the dedicated `set_root_weights` extrinsic since the generic set_weights rejects netuid 0 and root needs different checks. Retain the dust-recycle fix (Σ owed == BasketPrincipal). Co-authored-by: Cursor --- pallets/subtensor/src/lib.rs | 9 ------ pallets/subtensor/src/staking/claim_root.rs | 10 +++++-- pallets/subtensor/src/subnets/weights.rs | 5 ++-- pallets/subtensor/src/swap/swap_hotkey.rs | 7 ----- pallets/subtensor/src/tests/claim_root.rs | 17 +++++------ .../src/tests/swap_hotkey_with_subnet.rs | 28 ++++++++++++++++--- 6 files changed, 43 insertions(+), 33 deletions(-) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index f9c9f95f39..6d8d269b61 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -2509,15 +2509,6 @@ pub mod pallet { DefaultZeroAlpha, >; - /// --- MAP ( validator_hotkey ) --> Vec<(subnet_id, weight)> | beta basket distribution vector. - /// - /// A root validator's beta-basket weight vector `w`, set via `set_root_weights`. Dedicated - /// storage (NOT the legacy `Weights[ROOT]` consensus map) so basket allocation never aliases - /// or is mutated by root-consensus / `remove_network` weight handling. Keyed by hotkey so it - /// is unaffected by root UID reuse and migrates cleanly on hotkey swap. - #[pallet::storage] - pub type RootBasketWeights = - StorageMap<_, Blake2_128Concat, T::AccountId, Vec<(u16, u16)>, ValueQuery>; #[pallet::storage] // -- MAP ( cold ) --> root_claim_type enum pub type RootClaimType = StorageMap< _, diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index fc175a44bc..148edd0ccf 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -7,6 +7,7 @@ use sp_runtime::DispatchError; use sp_runtime::traits::AccountIdConversion; use sp_std::collections::btree_set::BTreeSet; use substrate_fixed::types::{I96F32, U96F32}; +use subtensor_runtime_common::NetUidStorageIndex; use subtensor_swap_interface::SwapHandler; impl Pallet { @@ -112,8 +113,13 @@ impl Pallet { return; } - // Resolve the validator's beta basket weight vector w (dedicated storage). - let weights = RootBasketWeights::::get(hotkey); + // Resolve the validator's basket weight vector w = Weights[ROOT][uid]. The vector follows + // the validator's root uid (so it survives hotkey swaps automatically) and reuses the + // existing root weights plumbing. + let maybe_uid = Uids::::try_get(NetUid::ROOT, hotkey).ok(); + let weights = maybe_uid + .map(|uid| Weights::::get(NetUidStorageIndex::ROOT, uid)) + .unwrap_or_default(); // Keep only weights that point at existing, non-root subnets. let valid: Vec<(NetUid, u64)> = weights diff --git a/pallets/subtensor/src/subnets/weights.rs b/pallets/subtensor/src/subnets/weights.rs index e674c76058..3c345fdc11 100644 --- a/pallets/subtensor/src/subnets/weights.rs +++ b/pallets/subtensor/src/subnets/weights.rs @@ -992,14 +992,13 @@ impl Pallet { // --- 9. Max-upscale the weights. let max_upscaled_weights: Vec = vec_u16_max_upscale_to_u16(&values); - // --- 10. Zip and store in the dedicated beta-basket weights map (keyed by hotkey, NOT - // the legacy `Weights[ROOT]` consensus map, to avoid storage aliasing). + // --- 10. Zip and store under the root weights index (reusing the root weights plumbing). let zipped_weights: Vec<(u16, u16)> = dests .iter() .copied() .zip(max_upscaled_weights.iter().copied()) .collect(); - RootBasketWeights::::insert(&hotkey, zipped_weights); + Weights::::insert(NetUidStorageIndex::ROOT, neuron_uid, zipped_weights); // --- 11. Record activity for the rate limit. Self::set_last_update_for_uid(NetUidStorageIndex::ROOT, neuron_uid, current_block); diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs index 3d1e437f22..cd766036cd 100644 --- a/pallets/subtensor/src/swap/swap_hotkey.rs +++ b/pallets/subtensor/src/swap/swap_hotkey.rs @@ -664,13 +664,6 @@ impl Pallet { } } - // Move the validator's beta basket weight vector to the new hotkey. - if RootBasketWeights::::contains_key(old_hotkey) { - let w = RootBasketWeights::::take(old_hotkey); - RootBasketWeights::::insert(new_hotkey, w); - weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); - } - // Transfer AutoParentDelegationEnabled flag from old_hotkey to new_hotkey. // Only migrate if it was explicitly set, to preserve the storage default semantics. if AutoParentDelegationEnabled::::contains_key(old_hotkey) { diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index dbb819ff90..51599cfb1d 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -3,10 +3,10 @@ use crate::tests::mock::*; use crate::{ BasketPrincipal, DefaultMinRootClaimAmount, Error, Keys, MAX_NUM_ROOT_CLAIMS, - MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, NumRootClaim, NumStakingColdkeys, RootBasketWeights, - RootClaimType, RootClaimTypeEnum, RootClaimable, RootClaimableThreshold, RootClaimed, - StakingColdkeys, StakingColdkeysByIndex, SubnetAlphaIn, SubnetMovingPrice, SubnetTAO, - SubnetworkN, Tempo, TotalStake, Uids, + MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, NumRootClaim, NumStakingColdkeys, RootClaimType, + RootClaimTypeEnum, RootClaimable, RootClaimableThreshold, RootClaimed, StakingColdkeys, + StakingColdkeysByIndex, SubnetAlphaIn, SubnetMovingPrice, SubnetTAO, SubnetworkN, Tempo, + TotalStake, Uids, Weights, }; use approx::assert_abs_diff_eq; use frame_support::dispatch::RawOrigin; @@ -17,7 +17,7 @@ use sp_core::{H256, U256}; use sp_runtime::DispatchError; use std::collections::BTreeSet; use substrate_fixed::types::I96F32; -use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; +use subtensor_runtime_common::{AlphaBalance, NetUid, NetUidStorageIndex, TaoBalance, Token}; // ============================================================================= // Helpers @@ -26,9 +26,10 @@ use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; /// Directly assign a root UID and a beta-basket weight vector `w` to a validator hotkey, /// bypassing the `set_root_weights` extrinsic's validation (which is exercised separately). /// `dests` are `(subnet, weight)` pairs. -fn set_root_weights_direct(hotkey: &U256, _uid: u16, dests: &[(NetUid, u16)]) { +fn set_root_weights_direct(hotkey: &U256, uid: u16, dests: &[(NetUid, u16)]) { + Uids::::insert(NetUid::ROOT, hotkey, uid); let zipped: Vec<(u16, u16)> = dests.iter().map(|(n, w)| (u16::from(*n), *w)).collect(); - RootBasketWeights::::insert(hotkey, zipped); + Weights::::insert(NetUidStorageIndex::ROOT, uid, zipped); } /// Ensure a subnet has deep, balanced AMM reserves so basket swaps execute with negligible @@ -1046,7 +1047,7 @@ fn test_set_root_weights_stores_vector() { 0, )); - let stored = RootBasketWeights::::get(hotkey); + let stored = Weights::::get(NetUidStorageIndex::ROOT, 0u16); assert_eq!(stored, vec![(u16::from(netuid), u16::MAX)]); }); } diff --git a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs index 9da309fe8e..5fab746837 100644 --- a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs +++ b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs @@ -2470,7 +2470,12 @@ fn test_revert_claim_root_with_swap_hotkey() { // Route the validator's beta basket back into this subnet so dividends accrue. SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000u64)); SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); - RootBasketWeights::::insert(hk1, vec![(u16::from(netuid), u16::MAX)]); + Uids::::insert(NetUid::ROOT, hk1, 0u16); + Weights::::insert( + NetUidStorageIndex::ROOT, + 0u16, + vec![(u16::from(netuid), u16::MAX)], + ); let pending_root_alpha = 1_000_000u64; SubtensorModule::distribute_emission( @@ -2959,7 +2964,12 @@ fn test_swap_hotkey_root_claims_unchanged_if_not_root() { // Route the validator's beta basket back into this subnet so dividends accrue. SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000u64)); SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); - RootBasketWeights::::insert(neuron_hotkey, vec![(u16::from(netuid), u16::MAX)]); + Uids::::insert(NetUid::ROOT, neuron_hotkey, 0u16); + Weights::::insert( + NetUidStorageIndex::ROOT, + 0u16, + vec![(u16::from(netuid), u16::MAX)], + ); // Distribute pending root alpha let pending_root_alpha = 1_000_000_000u64; @@ -3043,7 +3053,12 @@ fn test_swap_hotkey_root_claims_changed_if_root() { // Route the validator's beta basket back into this subnet so dividends accrue. SubnetTAO::::insert(netuid_1, TaoBalance::from(1_000_000_000_000u64)); SubnetAlphaIn::::insert(netuid_1, AlphaBalance::from(1_000_000_000_000u64)); - RootBasketWeights::::insert(neuron_hotkey, vec![(u16::from(netuid_1), u16::MAX)]); + Uids::::insert(NetUid::ROOT, neuron_hotkey, 0u16); + Weights::::insert( + NetUidStorageIndex::ROOT, + 0u16, + vec![(u16::from(netuid_1), u16::MAX)], + ); // Distribute pending root alpha let pending_root_alpha = 1_000_000_000u64; @@ -3137,7 +3152,12 @@ fn test_swap_hotkey_root_claims_changed_if_all_subnets() { // Route the validator's beta basket back into this subnet so dividends accrue. SubnetTAO::::insert(netuid_1, TaoBalance::from(1_000_000_000_000u64)); SubnetAlphaIn::::insert(netuid_1, AlphaBalance::from(1_000_000_000_000u64)); - RootBasketWeights::::insert(neuron_hotkey, vec![(u16::from(netuid_1), u16::MAX)]); + Uids::::insert(NetUid::ROOT, neuron_hotkey, 0u16); + Weights::::insert( + NetUidStorageIndex::ROOT, + 0u16, + vec![(u16::from(netuid_1), u16::MAX)], + ); // Distribute pending root alpha let pending_root_alpha = 1_000_000_000u64; From 8f83d7122adbe64a0eaec0c43376db23301658ad Mon Sep 17 00:00:00 2001 From: unconst Date: Tue, 16 Jun 2026 12:20:19 -0600 Subject: [PATCH 3/8] Fix dissolve misallocation: liquidate basket by owed, not current stake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On subnet dissolve, liquidate_basket_to_root_stakers previously credited the swapped basket value to the validator's current root nominators in proportion to their *current root stake* (increase_stake_for_hotkey_on_subnet), ignoring the per-coldkey owed entitlement. That windfalls recent/large-current-stake nominators and short-changes stakers who actually accrued the basket, then wipes the ledger — a provable intra-staker fairness bug. Now: swap the whole basket once, then distribute the realized TAO pro-rata by each staker's owed (rate*root_stake - claimed == owed*E/P), crediting each coldkey individually and rebasing its claimed watermark (mirrors a normal claim). Degenerate zero-owed case falls back to stake-proportional so value is never orphaned. Adds a regression test proving a zero-owed fresh staker receives nothing while the accruing staker receives the basket. Co-authored-by: Cursor --- pallets/subtensor/src/staking/claim_root.rs | 73 +++++++++++++++---- pallets/subtensor/src/tests/claim_root.rs | 78 +++++++++++++++++++++ 2 files changed, 139 insertions(+), 12 deletions(-) diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 148edd0ccf..44a81633ae 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -621,11 +621,12 @@ impl Pallet { /// Liquidates a validator's beta basket on `netuid` back to its root stakers. /// - /// Used when a subnet is dissolved: the escrow position `(hotkey, H, netuid)` is removed, - /// swapped to TAO, and credited to the validator's root nominators (proportional to their - /// root stake) via the root share pool — so basket value reaches the actual stakers instead - /// of being orphaned in the escrow account by subnet teardown. Best-effort: swap failures are - /// logged and the slot is left for subnet teardown to handle. + /// Used when a subnet is dissolved: the escrow position `(hotkey, H, netuid)` is removed and + /// swapped to TAO once, then the proceeds are credited to each root staker **in proportion to + /// their owed basket entitlement** (`owed_c = rate · root_stake − claimed`, i.e. the same + /// `owed · E/P` a normal claim would pay), NOT their current root-stake share. Distributing by + /// current stake would windfall recent/large stakers and short-change stakers who actually + /// accrued the basket. Best-effort: swap failures are logged and the slot is left for teardown. pub fn liquidate_basket_to_root_stakers( hotkey: &T::AccountId, escrow: &T::AccountId, @@ -645,7 +646,8 @@ impl Pallet { basket_alpha, ); - // Swap the basket alpha to TAO. + // Swap the whole basket to TAO once (one swap => no per-staker ordering slippage; the + // realized TAO is then split by owed-proportion, which equals each staker's `owed·E/P`). let owed_tao = match Self::swap_alpha_for_tao( netuid, basket_alpha, @@ -679,12 +681,9 @@ impl Pallet { Self::record_protocol_outflow(netuid, owed_tao.amount_paid_out); - // Credit the validator's root nominators proportionally to their root stake. - Self::increase_stake_for_hotkey_on_subnet( - hotkey, - NetUid::ROOT, - owed_tao.amount_paid_out.to_u64().into(), - ); + let tao_total: u64 = owed_tao.amount_paid_out.to_u64(); + + // Move the TAO onto root (aggregate); per-coldkey shares are credited below. SubnetTAO::::mutate(NetUid::ROOT, |total| { *total = total.saturating_add(owed_tao.amount_paid_out.into()); }); @@ -695,6 +694,56 @@ impl Pallet { *total = total.saturating_add(owed_tao.amount_paid_out.into()); }); + // Gather this validator's root stakers and their owed basket entitlement. + let coldkeys: BTreeSet = Self::alpha_iter_single_prefix(hotkey) + .filter(|(_, n, _)| *n == NetUid::ROOT) + .map(|(coldkey, _, _)| coldkey) + .collect(); + let mut owed_list: Vec<(T::AccountId, u128)> = Vec::new(); + let mut total_owed: u128 = 0; + for coldkey in coldkeys { + let owed = Self::get_root_owed_for_hotkey_coldkey(hotkey, &coldkey, netuid) as u128; + if owed > 0 { + total_owed = total_owed.saturating_add(owed); + owed_list.push((coldkey, owed)); + } + } + + // Degenerate case (no current staker is owed, e.g. all already claimed): fall back to + // proportional-by-stake so the value is not orphaned in the root account. + if total_owed == 0 { + Self::increase_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT, tao_total.into()); + return TransactionOutcome::Commit(Ok::<(), DispatchError>(())); + } + + // Distribute the realized TAO pro-rata by owed; the last staker absorbs the remainder + // so the full amount is allocated. + let mut distributed: u64 = 0; + let last_idx = owed_list.len().saturating_sub(1); + for (i, (coldkey, owed)) in owed_list.iter().enumerate() { + let tao_c: u64 = if i == last_idx { + tao_total.saturating_sub(distributed) + } else { + (tao_total as u128) + .saturating_mul(*owed) + .checked_div(total_owed) + .unwrap_or(0) as u64 + }; + distributed = distributed.saturating_add(tao_c); + if tao_c == 0 { + continue; + } + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + tao_c.into(), + ); + // Rebase this staker's claimed watermark for the new root stake so it does not + // inflate their claimable on other subnets' baskets (mirrors a normal claim). + Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(hotkey, coldkey, tao_c); + } + TransactionOutcome::Commit(Ok::<(), DispatchError>(())) }); } diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 51599cfb1d..5030093142 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -687,6 +687,84 @@ fn test_root_basket_dissolve_liquidates_to_stakers() { }); } +/// Dissolve liquidation must distribute by each staker's *owed* basket entitlement, NOT by +/// current root-stake share. A "fresh" staker who joined after the basket accrued (zero owed) +/// must receive nothing, even with an equal current root stake — otherwise they'd windfall at +/// the expense of the staker who actually funded the basket. +#[test] +fn test_root_basket_dissolve_distributes_by_owed_not_stake() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let alice = U256::from(1003); + let bob = U256::from(1004); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + + // Alice is the sole root staker while the basket accrues — she funds all of it. + let stake = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &alice, + NetUid::ROOT, + stake.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + assert!(escrow_alpha(&hotkey, netuid) > 0); + + // Bob joins AFTER accrual with the SAME root stake; his watermark is rebased exactly as + // real `add_stake` would, so his owed entitlement is zero. + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &bob, + NetUid::ROOT, + stake.into(), + ); + SubtensorModule::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(&hotkey, &bob, stake); + + // Equal current root stake, but only Alice is owed the basket. + assert_eq!(root_stake_of(&hotkey, &alice), root_stake_of(&hotkey, &bob)); + assert!(SubtensorModule::get_root_owed_for_hotkey_coldkey(&hotkey, &alice, netuid) > 0); + assert_eq!( + SubtensorModule::get_root_owed_for_hotkey_coldkey(&hotkey, &bob, netuid), + 0 + ); + + let alice_before = root_stake_of(&hotkey, &alice); + let bob_before = root_stake_of(&hotkey, &bob); + + assert_ok!(SubtensorModule::do_dissolve_network(netuid)); + + let alice_gain = root_stake_of(&hotkey, &alice).saturating_sub(alice_before); + let bob_gain = root_stake_of(&hotkey, &bob).saturating_sub(bob_before); + + // The basket goes to Alice (who accrued it); Bob (zero owed) gets nothing — even though + // a stake-proportional split would have handed him ~half. + assert!(alice_gain > 0, "accruing staker must receive the basket"); + assert_eq!( + bob_gain, 0, + "fresh staker with zero owed must receive nothing" + ); + }); +} + // ============================================================================= // Beta basket: conservation invariants ("prove it works") // ============================================================================= From ced07a5da39f65c3c4febc14b7fa957c8801cc93 Mon Sep 17 00:00:00 2001 From: unconst Date: Tue, 16 Jun 2026 12:45:30 -0600 Subject: [PATCH 4/8] Beta basket: deposit at NAV so late stakers can't dilute or skim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mint basket principal *shares* at the live escrow NAV (E/P) instead of at par. A deposit into an already-compounded basket now mints fewer shares than the alpha bought, leaving E/P unchanged: existing holders are not diluted and a late staker cannot skim past compounding. Makes the staker-facing guarantee strict — a new staker only ever earns their fair share of distributions from the point they join forward. Adds tests proving claims 1-4 (principal never lost; accrued beta unchanged by others staking; beta compounds; no dilution/skim on late stake, incl. E/P invariance across a deposit). Also includes the dissolve liquidation distributing pro-rata by owed entitlement rather than current root share. Co-authored-by: Cursor --- pallets/subtensor/src/lib.rs | 16 +- pallets/subtensor/src/staking/claim_root.rs | 39 ++- pallets/subtensor/src/tests/claim_root.rs | 293 ++++++++++++++++++++ 3 files changed, 331 insertions(+), 17 deletions(-) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 6d8d269b61..4b2b6e3b37 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -2489,14 +2489,16 @@ pub mod pallet { ValueQuery, >; - /// --- DMAP ( validator_hotkey, netuid ) --> outstanding basket principal (alpha). + /// --- DMAP ( validator_hotkey, netuid ) --> outstanding basket principal *shares*. /// - /// Total un-claimed alpha *principal* that root stakers have contributed to this - /// validator's beta basket on `netuid`. The actual basket alpha is staked to the - /// validator under the global beta escrow coldkey and grows with dividends; the - /// per-staker payout at claim time is `owed_principal * (escrow_value / BasketPrincipal)`, - /// which captures that compounding. Kept in alpha (not shares) so it survives hotkey - /// swaps, where positions migrate by value. + /// Total un-claimed principal shares root stakers hold in this validator's beta basket on + /// `netuid`. The actual basket alpha is staked to the validator under the global beta escrow + /// coldkey (value `E`) and grows with dividends; the per-staker payout at claim time is + /// `owed_shares * (E / BasketPrincipal)`, which captures that compounding. Deposits mint + /// shares at the live NAV (`E/P`), not at par, so a deposit into an already-compounded basket + /// leaves `E/P` unchanged — existing holders are not diluted and late stakers cannot skim + /// past compounding. At a flat NAV (`E == P`, e.g. right after the seed migration) one share + /// equals one alpha, so this also migrates cleanly by value on hotkey swap. #[pallet::storage] pub type BasketPrincipal = StorageDoubleMap< _, diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 44a81633ae..11058ea8d5 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -191,21 +191,40 @@ impl Pallet { continue; } - // Per-staker claimable rate increment: bought alpha per unit of root stake. - let increment: I96F32 = I96F32::saturating_from_num(bought) + // Mint basket principal at the CURRENT escrow NAV, not at par. A deposit into an + // already-compounded basket (E/P > 1) must mint fewer principal "shares" than the + // alpha bought, so E/P is left unchanged: existing holders are not diluted and a + // late staker cannot skim past compounding. shares = bought / (E/P) = bought*P/E. + let escrow_value: u64 = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *dest_netuid) + .to_u64(); + let principal_total: u64 = BasketPrincipal::::get(hotkey, *dest_netuid).to_u64(); + let bought_u64: u64 = bought.to_u64(); + let shares: u64 = if principal_total == 0 || escrow_value == 0 { + // First deposit into this slot: 1 principal share per unit (E/P starts at 1). + bought_u64 + } else { + U96F32::saturating_from_num(bought_u64) + .saturating_mul(U96F32::saturating_from_num(principal_total)) + .checked_div(U96F32::saturating_from_num(escrow_value)) + .unwrap_or(U96F32::saturating_from_num(0)) + .saturating_to_num::() + }; + + // Per-staker claimable rate increment: principal shares per unit of root stake. + let increment: I96F32 = I96F32::saturating_from_num(shares) .checked_div(total_root_float) .unwrap_or(I96F32::saturating_from_num(0)); - // If the increment underflows to zero (bought is tiny relative to the root pool), - // crediting would grow principal/escrow with no claimable rate, stranding the - // value. Recycle this slot's alpha instead, keeping `Σ owed == BasketPrincipal` - // exact. (TAO stays neutral: the buy's `tao_s` already balances the origin sell.) - if increment == I96F32::saturating_from_num(0) { + // Too small to credit (shares or rate round to zero): recycle so the escrow never + // grows without matching claimable principal (keeps `Σ owed == BasketPrincipal`). + if shares == 0 || increment == I96F32::saturating_from_num(0) { Self::recycle_subnet_alpha(*dest_netuid, bought); continue; } - // Stake the bought alpha to the validator under the escrow coldkey. + // Stake the full `bought` alpha to the validator under the escrow coldkey (grows E + // by `bought`); P grows only by `shares`, so E/P is preserved on deposit. Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, &escrow, @@ -213,9 +232,9 @@ impl Pallet { bought, ); - // Record basket principal (alpha) for the E/P compounding multiplier. + // Record basket principal as NAV shares (not face alpha). BasketPrincipal::::mutate(hotkey, *dest_netuid, |p| { - *p = p.saturating_add(bought); + *p = p.saturating_add(shares.into()); }); Self::bump_root_claimable_rate(hotkey, *dest_netuid, increment); diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 5030093142..79290107e3 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -1130,6 +1130,299 @@ fn test_set_root_weights_stores_vector() { }); } +// ============================================================================= +// Claims 1-4: the staker-facing guarantees, proven directly. +// ============================================================================= + +/// CLAIM 1 — staking principal can never be lost: the basket only ever deploys the validator's +/// dividends, never the staker's root principal. A distribution leaves the staker's root stake +/// untouched, and a claim only ever *adds* to it. +#[test] +fn test_claim1_principal_never_lost() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); + + let principal = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + principal.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + // Dividend distribution did not touch the staker's root principal. + assert_eq!(root_stake_of(&hotkey, &coldkey), principal); + + // Claiming only adds TAO to the root principal (never subtracts). + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from([netuid]) + )); + assert!(root_stake_of(&hotkey, &coldkey) >= principal); + }); +} + +/// CLAIM 2 — accrued beta is unaffected by *others* staking the same validator: another staker +/// joining does not change your already-accrued basket value, and they accrue nothing of yours. +#[test] +fn test_claim2_accrued_basket_unchanged_when_others_stake() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let alice = U256::from(1003); + let bob = U256::from(1004); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &alice, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + let alice_before = SubtensorModule::get_basket_payout_alpha(&hotkey, &alice, netuid); + assert!(alice_before > 0); + + // Bob stakes the same validator (no new distribution). The mock stake helper bypasses the + // root-claimed watermark that the real add_stake applies, so set it explicitly. + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &bob, + NetUid::ROOT, + 5_000_000u64.into(), + ); + SubtensorModule::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( + &hotkey, + &bob, + 5_000_000u64, + ); + + // Alice's accrued basket is unchanged; Bob has accrued nothing of it. + assert_eq!( + SubtensorModule::get_basket_payout_alpha(&hotkey, &alice, netuid), + alice_before + ); + assert_eq!( + SubtensorModule::get_basket_payout_alpha(&hotkey, &bob, netuid), + 0 + ); + }); +} + +/// CLAIM 3 — earned beta compounds: while it sits staked under the validator it earns the +/// validator's subnet dividends, so the staker's claimable value grows beyond what they earned. +#[test] +fn test_claim3_basket_compounds() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + let before = SubtensorModule::get_basket_payout_alpha(&hotkey, &coldkey, netuid); + assert!(before > 0); + + // The validator earns subnet dividends on the basket position (escrow value grows). + let escrow = SubtensorModule::get_beta_escrow_account_id(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &escrow, + netuid, + before.into(), + ); + + // The sole staker's claimable value compounded upward. + assert!(SubtensorModule::get_basket_payout_alpha(&hotkey, &coldkey, netuid) > before); + }); +} + +/// CLAIM 4 — a late staker can neither claim the existing basket nor skim its past compounding. +/// Proven two ways: (a) a fresh staker's owed is zero, and (b) a deposit into an already +/// compounded basket leaves the `E/P` multiplier unchanged (deposit-at-NAV), so the late +/// staker only ever earns their fair share of *new* distributions — never the old compounding. +#[test] +fn test_claim4_no_dilution_or_skim_on_late_stake() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let alice = U256::from(1003); + let bob = U256::from(1004); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid, I96F32::from_num(0)); + + // Equal root stake for Alice and Bob. + let stake = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &alice, + NetUid::ROOT, + stake.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + // Alice accrues a basket. + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + // The basket compounds heavily (escrow value grows ~4x; principal unchanged). + let escrow = SubtensorModule::get_beta_escrow_account_id(); + let e0 = escrow_alpha(&hotkey, netuid); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &escrow, + netuid, + (3 * e0).into(), + ); + + let mult = |hk: &U256| -> f64 { + let e = escrow_alpha(hk, netuid) as f64; + let p = u64::from(BasketPrincipal::::get(hk, netuid)) as f64; + e / p + }; + let mult_before = mult(&hotkey); + assert!( + mult_before > 3.0, + "basket should have compounded, got {mult_before}" + ); + let alice_before = SubtensorModule::get_basket_payout_alpha(&hotkey, &alice, netuid); + + // Bob stakes the heavily-compounded validator. The mock stake helper bypasses the + // root-claimed watermark that the real add_stake applies, so set it explicitly. + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &bob, + NetUid::ROOT, + stake.into(), + ); + SubtensorModule::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(&hotkey, &bob, stake); + + // (4a) Bob cannot claim any of the existing basket; Alice's accrual is untouched. + assert_eq!( + SubtensorModule::get_basket_payout_alpha(&hotkey, &bob, netuid), + 0 + ); + assert_eq!( + SubtensorModule::get_basket_payout_alpha(&hotkey, &alice, netuid), + alice_before + ); + + // A new distribution deposits into the already-compounded basket. + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + // (4b) Deposit-at-NAV: the E/P multiplier is unchanged, so no dilution occurred. + let mult_after = mult(&hotkey); + assert_abs_diff_eq!(mult_after, mult_before, epsilon = 0.02); + + let alice_after = SubtensorModule::get_basket_payout_alpha(&hotkey, &alice, netuid); + let bob_after = SubtensorModule::get_basket_payout_alpha(&hotkey, &bob, netuid); + + // Alice was not diluted: her value only grew. + assert!(alice_after >= alice_before); + + // The new distribution split fairly (equal root stake) — and crucially, Bob's *entire* + // basket equals only Alice's *increment* from the new distribution. Bob captured none of + // Alice's pre-existing compounding (alice_before). + let alice_increment = alice_after.saturating_sub(alice_before); + assert!(bob_after > 0); + assert_abs_diff_eq!(alice_increment, bob_after, epsilon = 1_000u64); + assert!( + bob_after < alice_before, + "late staker skimmed past compounding: bob={bob_after} alice_before={alice_before}" + ); + }); +} + /// The read-only views (RPC surface) report the basket correctly: a sole staker's "owed TAO" /// equals the validator NAV equals the network total, and the breakdown lists the slot. #[test] From 02378dd15f77d637c94d09a7ab083f212db1300f Mon Sep 17 00:00:00 2001 From: unconst Date: Tue, 16 Jun 2026 13:28:52 -0600 Subject: [PATCH 5/8] Beta basket: lifecycle events + validator-weights RPC view Add observability for off-chain indexing / future tokenization cost-basis: - Events: BasketDeposited (alpha bought + shares minted at NAV, per validator/subnet), BasketClaimed (TAO realized by a staker), and BasketLiquidated (TAO returned to root stakers on subnet dissolve). - RPC/runtime-API: betaBasket_getValidatorWeights returns a validator's basket weight vector (its curation strategy) so dashboards can display it. Co-authored-by: Cursor --- pallets/subtensor/rpc/src/lib.rs | 23 +++++++++++++ pallets/subtensor/runtime-api/src/lib.rs | 2 ++ pallets/subtensor/src/macros/events.rs | 37 +++++++++++++++++++++ pallets/subtensor/src/staking/claim_root.rs | 32 ++++++++++++++++++ runtime/src/lib.rs | 3 ++ 5 files changed, 97 insertions(+) diff --git a/pallets/subtensor/rpc/src/lib.rs b/pallets/subtensor/rpc/src/lib.rs index 08140eaa6a..480cb43778 100644 --- a/pallets/subtensor/rpc/src/lib.rs +++ b/pallets/subtensor/rpc/src/lib.rs @@ -143,6 +143,13 @@ pub trait SubtensorCustomApi { /// Network-wide total beta basket NAV across all validators, in TAO. #[method(name = "betaBasket_getTotalNav")] fn get_root_basket_total_nav(&self, at: Option) -> RpcResult; + /// A validator's basket weight vector: SCALE-encoded `Vec<(NetUid, u16)>` (its strategy). + #[method(name = "betaBasket_getValidatorWeights")] + fn get_validator_weights( + &self, + hotkey: AccountId32, + at: Option, + ) -> RpcResult>; } pub struct SubtensorCustom { @@ -662,4 +669,20 @@ where } } } + + fn get_validator_weights( + &self, + hotkey: AccountId32, + at: Option<::Hash>, + ) -> RpcResult> { + let api = self.client.runtime_api(); + let at = at.unwrap_or_else(|| self.client.info().best_hash); + + match api.get_validator_weights(at, hotkey) { + Ok(result) => Ok(result.encode()), + Err(e) => { + Err(Error::RuntimeError(format!("Unable to get validator weights: {e:?}")).into()) + } + } + } } diff --git a/pallets/subtensor/runtime-api/src/lib.rs b/pallets/subtensor/runtime-api/src/lib.rs index 2b8f08116a..3e06a3da09 100644 --- a/pallets/subtensor/runtime-api/src/lib.rs +++ b/pallets/subtensor/runtime-api/src/lib.rs @@ -91,5 +91,7 @@ sp_api::decl_runtime_apis! { fn get_validator_basket(hotkey: AccountId32) -> Vec<(NetUid, AlphaBalance, TaoBalance)>; /// Network-wide total beta basket NAV across all validators, in TAO (marked). fn get_root_basket_total_nav() -> TaoBalance; + /// A validator's basket weight vector `w`: (subnet, weight) it deploys dividends into. + fn get_validator_weights(hotkey: AccountId32) -> Vec<(NetUid, u16)>; } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 23f121e535..a8b1273cb6 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -483,6 +483,43 @@ mod events { root_claim_type: RootClaimTypeEnum, }, + /// A validator's beta basket received a deposit on a subnet: `alpha` was bought and staked + /// into the basket, minting `shares` of basket principal at the current NAV. + BasketDeposited { + /// Validator hotkey whose basket received the deposit. + hotkey: T::AccountId, + /// Subnet the basket alpha was bought on. + netuid: NetUid, + /// Alpha bought and staked into the basket (grows escrow value `E`). + alpha: AlphaBalance, + /// Basket principal shares minted at the live NAV (grows `BasketPrincipal`). + shares: AlphaBalance, + }, + + /// A staker redeemed (claimed) part of a validator's beta basket on a subnet, realizing + /// `tao` which was staked onto their root position. + BasketClaimed { + /// Validator hotkey the basket belongs to. + hotkey: T::AccountId, + /// Staker coldkey that claimed. + coldkey: T::AccountId, + /// Subnet the basket alpha was redeemed from. + netuid: NetUid, + /// TAO realized and staked on root for the staker. + tao: TaoBalance, + }, + + /// A validator's beta basket on a dissolving subnet was liquidated back to its root + /// stakers, realizing `tao` distributed to the validator's root nominators. + BasketLiquidated { + /// Validator hotkey whose basket was liquidated. + hotkey: T::AccountId, + /// Subnet being dissolved. + netuid: NetUid, + /// TAO realized and credited to the validator's root stakers. + tao: TaoBalance, + }, + /// Voting power tracking has been enabled for a subnet. VotingPowerTrackingEnabled { /// The subnet ID diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 11058ea8d5..0013930cbe 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -238,6 +238,13 @@ impl Pallet { }); Self::bump_root_claimable_rate(hotkey, *dest_netuid, increment); + + Self::deposit_event(Event::BasketDeposited { + hotkey: hotkey.clone(), + netuid: *dest_netuid, + alpha: bought, + shares: shares.into(), + }); } TransactionOutcome::Commit(Ok(())) @@ -431,6 +438,13 @@ impl Pallet { owed_tao.amount_paid_out.into(), ); + Self::deposit_event(Event::BasketClaimed { + hotkey: hotkey.clone(), + coldkey: coldkey.clone(), + netuid, + tao: owed_tao.amount_paid_out, + }); + TransactionOutcome::Commit(Ok(())) })?; @@ -713,6 +727,12 @@ impl Pallet { *total = total.saturating_add(owed_tao.amount_paid_out.into()); }); + Self::deposit_event(Event::BasketLiquidated { + hotkey: hotkey.clone(), + netuid, + tao: owed_tao.amount_paid_out, + }); + // Gather this validator's root stakers and their owed basket entitlement. let coldkeys: BTreeSet = Self::alpha_iter_single_prefix(hotkey) .filter(|(_, n, _)| *n == NetUid::ROOT) @@ -888,4 +908,16 @@ impl Pallet { } nav.into() } + + /// A validator's beta basket weight vector `w`: the `(subnet, weight)` pairs it deploys its + /// root dividends into (its curation strategy), exactly as stored. + pub fn get_validator_root_weights(hotkey: &T::AccountId) -> Vec<(NetUid, u16)> { + Uids::::try_get(NetUid::ROOT, hotkey) + .ok() + .map(|uid| Weights::::get(NetUidStorageIndex::ROOT, uid)) + .unwrap_or_default() + .into_iter() + .map(|(dest, weight)| (NetUid::from(dest), weight)) + .collect() + } } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 3a74829ebb..3497d75821 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -2592,6 +2592,9 @@ impl_runtime_apis! { fn get_root_basket_total_nav() -> TaoBalance { SubtensorModule::get_root_basket_total_nav_tao() } + fn get_validator_weights(hotkey: AccountId32) -> Vec<(NetUid, u16)> { + SubtensorModule::get_validator_root_weights(&hotkey) + } } impl subtensor_custom_rpc_runtime_api::ProxyFilterRuntimeApi for Runtime { From 2a6bdeb42278e87dbe4a77647a27fbf2b74d2ed8 Mon Sep 17 00:00:00 2001 From: unconst Date: Tue, 16 Jun 2026 15:16:01 -0600 Subject: [PATCH 6/8] Beta basket: symmetric flow accounting, escrow sweep guard, deprecate claim type - Record protocol outflow on the origin sell and inflow on each redistribution buy in distribute_root_alpha_to_basket, so a deposit->claim round-trip nets ~0 on the dest pools (symmetric with the claim/liquidation outflow that was already recorded). Records sit inside with_transaction so they roll back with the swaps. - Exclude the beta-escrow coldkey from clear_small_nomination_if_required: basket positions are not nominations, and sweeping one stranded TAO in the keyless escrow account while leaving BasketPrincipal untouched (breaking Sum(owed) == BasketPrincipal and zeroing every staker's owed * E/P payout). - Deprecate the claim-type surface: set_root_claim_type now rejects the no-op Keep/KeepSubnets variants (new RootClaimTypeNotSupported error); fixed the false "(Keep was removed)" comment and documented the variants as no-ops. - Remove dead auto-claim machinery (run_auto_claim_root_divs, block_hash_to_indices, block_hash_to_indices_weight) and its false-coverage test; update affected tests and benchmarks. Co-authored-by: Cursor --- pallets/subtensor/src/benchmarks.rs | 4 +- pallets/subtensor/src/lib.rs | 10 +- pallets/subtensor/src/macros/dispatches.rs | 13 +- pallets/subtensor/src/macros/errors.rs | 3 + pallets/subtensor/src/staking/claim_root.rs | 74 ++------ pallets/subtensor/src/staking/helpers.rs | 8 + pallets/subtensor/src/tests/claim_root.rs | 175 +++++++++++++----- .../src/tests/swap_hotkey_with_subnet.rs | 12 -- 8 files changed, 173 insertions(+), 126 deletions(-) diff --git a/pallets/subtensor/src/benchmarks.rs b/pallets/subtensor/src/benchmarks.rs index 2a08e4b933..385248193c 100644 --- a/pallets/subtensor/src/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks.rs @@ -1912,7 +1912,7 @@ mod pallet_benchmarks { let coldkey: T::AccountId = whitelisted_caller(); #[extrinsic_call] - _(RawOrigin::Signed(coldkey.clone()), RootClaimTypeEnum::Keep); + _(RawOrigin::Signed(coldkey.clone()), RootClaimTypeEnum::Swap); } #[benchmark] @@ -1971,7 +1971,7 @@ mod pallet_benchmarks { assert_ok!(Subtensor::::set_root_claim_type( RawOrigin::Signed(coldkey.clone()).into(), - RootClaimTypeEnum::Keep + RootClaimTypeEnum::Swap )); #[extrinsic_call] diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 4b2b6e3b37..ff470ad3e1 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -337,13 +337,19 @@ pub mod pallet { Encode, Decode, Default, TypeInfo, Clone, PartialEq, Eq, Debug, DecodeWithMemTracking, )] /// Enum for the per-coldkey root claim setting. + /// + /// With beta baskets, redemption is always a full swap to root TAO, so `Swap` is the only + /// supported variant. `Keep` and `KeepSubnets` are deprecated no-ops kept solely for + /// storage/SCALE decode compatibility with values written before the basket model; they are + /// rejected by `set_root_claim_type` and ignored by the claim path. pub enum RootClaimTypeEnum { /// Swap any alpha emission for TAO. #[default] Swap, - /// Keep all alpha emission. + /// Deprecated no-op (formerly: keep all alpha emission). Rejected by `set_root_claim_type`. Keep, - /// Keep all alpha emission for specified subnets. + /// Deprecated no-op (formerly: keep alpha emission for specified subnets). Rejected by + /// `set_root_claim_type`. KeepSubnets { /// Subnets to keep alpha emissions (swap everything else). subnets: BTreeSet, diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 47c2eeb5df..1514045c4f 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2202,6 +2202,12 @@ mod dispatches { } /// --- Sets the root claim type for the coldkey. + /// + /// Beta-basket redemption is always a full swap to root TAO, so only + /// [`RootClaimTypeEnum::Swap`] is accepted. The `Keep` / `KeepSubnets` variants are + /// deprecated no-ops retained only for storage/SCALE decode compatibility and are + /// rejected here so a caller can never set a claim type that silently does nothing. + /// /// # Args: /// * 'origin': (Origin): /// - The signature of the caller's coldkey. @@ -2218,9 +2224,10 @@ mod dispatches { ) -> DispatchResult { let coldkey: T::AccountId = ensure_signed(origin)?; - if let RootClaimTypeEnum::KeepSubnets { subnets } = &new_root_claim_type { - ensure!(!subnets.is_empty(), Error::::InvalidSubnetNumber); - } + ensure!( + matches!(new_root_claim_type, RootClaimTypeEnum::Swap), + Error::::RootClaimTypeNotSupported + ); Self::maybe_add_coldkey_index(&coldkey); diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 46343b6ed1..b005d761d8 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -265,6 +265,9 @@ mod errors { ChildParentInconsistency, /// Invalid number of root claims InvalidNumRootClaim, + /// The requested root claim type is no longer supported (only `Swap` is accepted; the + /// `Keep`/`KeepSubnets` variants are deprecated no-ops). + RootClaimTypeNotSupported, /// Invalid value of root claim threshold InvalidRootClaimThreshold, /// Exceeded subnet limit number or zero. diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 0013930cbe..c2c7ac351c 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -11,37 +11,6 @@ use subtensor_runtime_common::NetUidStorageIndex; use subtensor_swap_interface::SwapHandler; impl Pallet { - pub fn block_hash_to_indices(block_hash: T::Hash, k: u64, n: u64) -> Vec { - let block_hash_bytes = block_hash.as_ref(); - let mut indices: BTreeSet = BTreeSet::new(); - // k < n - let start_index: u64 = u64::from_be_bytes( - block_hash_bytes - .get(0..8) - .unwrap_or(&[0; 8]) - .try_into() - .unwrap_or([0; 8]), - ); - let mut last_idx = start_index; - for i in 0..k { - let bh_idx: usize = ((i.saturating_mul(8)) % 32) as usize; - let idx_step = u64::from_be_bytes( - block_hash_bytes - .get(bh_idx..(bh_idx.saturating_add(8))) - .unwrap_or(&[0; 8]) - .try_into() - .unwrap_or([0; 8]), - ); - let idx = last_idx - .saturating_add(idx_step) - .checked_rem(n) - .unwrap_or(0); - indices.insert(idx); - last_idx = idx; - } - indices.into_iter().collect() - } - pub fn increase_root_claimable_for_hotkey_and_subnet( hotkey: &T::AccountId, netuid: NetUid, @@ -104,6 +73,11 @@ impl Pallet { /// The whole operation is transactional: if any swap fails, it is rolled back and the original /// alpha is recycled. If the validator has no usable weights (or no root stake), the dividend /// is recycled. + /// + /// 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_on_subnet`). pub fn distribute_root_alpha_to_basket( hotkey: &T::AccountId, origin_netuid: NetUid, @@ -158,6 +132,9 @@ impl Pallet { Err(err) => return TransactionOutcome::Rollback(Err(err)), }; + // Record the origin-subnet root sell as protocol outflow (TAO left A's pool). + Self::record_protocol_outflow(origin_netuid, tao_total); + // 2. Split the TAO across subnets per w and buy each subnet's alpha. let tao_total_u64: u64 = tao_total.to_u64(); let mut spent: u64 = 0; @@ -187,6 +164,10 @@ impl Pallet { Ok(res) => res.amount_paid_out, Err(err) => return TransactionOutcome::Rollback(Err(err)), }; + + // Record the redistribution buy as protocol inflow (TAO entered B/C/D's pool). + Self::record_protocol_inflow(*dest_netuid, tao_s.into()); + if bought.is_zero() { continue; } @@ -317,7 +298,8 @@ impl Pallet { /// basket's live growth multiplier `E / P` (escrow value over outstanding principal) to get /// the current payout, that payout alpha is removed from the escrow position, swapped to TAO, /// and staked on root for the staker. `root_claim_type` is retained for signature - /// compatibility but no longer branches behavior (Keep was removed). + /// compatibility but no longer branches behavior: redemption is always a full swap. The + /// `Keep`/`KeepSubnets` variants are deprecated no-ops (rejected by `set_root_claim_type`). pub fn root_claim_on_subnet( hotkey: &T::AccountId, coldkey: &T::AccountId, @@ -574,11 +556,6 @@ impl Pallet { Ok(weight) } - fn block_hash_to_indices_weight(k: u64, _n: u64) -> Weight { - Weight::from_parts(3_000_000, 1517) - .saturating_add(Weight::from_parts(100_412, 0).saturating_mul(k.into())) - } - pub fn maybe_add_coldkey_index(coldkey: &T::AccountId) { if !StakingColdkeys::::contains_key(coldkey) { let n = NumStakingColdkeys::::get(); @@ -588,29 +565,6 @@ impl Pallet { } } - pub fn run_auto_claim_root_divs(last_block_hash: T::Hash) -> Weight { - let mut weight: Weight = Weight::default(); - - let n = NumStakingColdkeys::::get(); - let k = NumRootClaim::::get(); - weight.saturating_accrue(T::DbWeight::get().reads(2)); - - let coldkeys_to_claim: Vec = Self::block_hash_to_indices(last_block_hash, k, n); - weight.saturating_accrue(Self::block_hash_to_indices_weight(k, n)); - - for i in coldkeys_to_claim.iter() { - weight.saturating_accrue(T::DbWeight::get().reads(1)); - if let Ok(coldkey) = StakingColdkeysByIndex::::try_get(i) { - match Self::do_root_claim(coldkey.clone(), None) { - Ok(claim_weight) => weight.saturating_accrue(claim_weight), - Err(err) => log::error!("Error auto-claiming root dividends: {err:?}"), - } - } - } - - weight - } - pub fn change_root_claim_type(coldkey: &T::AccountId, new_type: RootClaimTypeEnum) { RootClaimType::::insert(coldkey.clone(), new_type.clone()); diff --git a/pallets/subtensor/src/staking/helpers.rs b/pallets/subtensor/src/staking/helpers.rs index f11012f0e2..17dc04fad2 100644 --- a/pallets/subtensor/src/staking/helpers.rs +++ b/pallets/subtensor/src/staking/helpers.rs @@ -229,6 +229,14 @@ impl Pallet { coldkey: &T::AccountId, netuid: NetUid, ) { + // The beta-basket escrow holds validator basket positions `(hotkey, escrow, netuid)`, which + // are not nominations. Sweeping one would force-unstake the basket alpha into the keyless + // escrow account (stranded, no controller) while leaving `BasketPrincipal` untouched, + // breaking `Σ owed == BasketPrincipal` and zeroing every staker's `owed * E/P` payout. + if *coldkey == Self::get_beta_escrow_account_id() { + return; + } + // Verify if the account is a nominator account by checking ownership of the hotkey by the coldkey. if !Self::coldkey_owns_hotkey(coldkey, hotkey) { // If the stake is non-zero and below the minimum required, it's considered a small nomination and needs to be cleared. diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 79290107e3..65286a9464 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -5,15 +5,15 @@ use crate::{ BasketPrincipal, DefaultMinRootClaimAmount, Error, Keys, MAX_NUM_ROOT_CLAIMS, MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, NumRootClaim, NumStakingColdkeys, RootClaimType, RootClaimTypeEnum, RootClaimable, RootClaimableThreshold, RootClaimed, StakingColdkeys, - StakingColdkeysByIndex, SubnetAlphaIn, SubnetMovingPrice, SubnetTAO, SubnetworkN, Tempo, - TotalStake, Uids, Weights, + StakingColdkeysByIndex, SubnetAlphaIn, SubnetMovingPrice, SubnetProtocolFlow, SubnetTAO, + SubnetworkN, Tempo, TotalStake, Uids, Weights, }; use approx::assert_abs_diff_eq; use frame_support::dispatch::RawOrigin; use frame_support::pallet_prelude::Weight; use frame_support::traits::Get; use frame_support::{assert_err, assert_noop, assert_ok}; -use sp_core::{H256, U256}; +use sp_core::U256; use sp_runtime::DispatchError; use std::collections::BTreeSet; use substrate_fixed::types::I96F32; @@ -58,53 +58,31 @@ fn test_claim_root_set_claim_type() { new_test_ext(1).execute_with(|| { let coldkey = U256::from(1); + // Swap is the only supported claim type. assert_ok!(SubtensorModule::set_root_claim_type( RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); - }); -} + RootClaimTypeEnum::Swap + )); + assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Swap); -#[test] -fn test_claim_root_block_hash_indices() { - new_test_ext(1).execute_with(|| { - let k = 15u64; - let n = 15000u64; - - // 0 - let indices = - SubtensorModule::block_hash_to_indices(H256(sp_core::keccak_256(b"zero")), 0, n); - assert!(indices.is_empty()); - - // 1 - let hash = sp_core::keccak_256(b"some"); - let mut indices = SubtensorModule::block_hash_to_indices(H256(hash), k, n); - indices.sort(); - - assert!(indices.len() <= k as usize); - assert!(!indices.iter().any(|i| *i >= n)); - // precomputed values - let expected_result = vec![ - 265, 630, 1286, 1558, 4496, 4861, 5517, 5789, 6803, 8096, 9092, 11034, 11399, 12055, - 12327, - ]; - assert_eq!(indices, expected_result); - - // 2 - let hash = sp_core::keccak_256(b"some2"); - let mut indices = SubtensorModule::block_hash_to_indices(H256(hash), k, n); - indices.sort(); - - assert!(indices.len() <= k as usize); - assert!(!indices.iter().any(|i| *i >= n)); - // precomputed values - let expected_result = vec![ - 61, 246, 1440, 2855, 3521, 5236, 6130, 6615, 8511, 9405, 9890, 11786, 11971, 13165, - 14580, - ]; - assert_eq!(indices, expected_result); + // Keep / KeepSubnets are deprecated no-ops and are rejected so a caller can never set a + // claim type that silently does nothing. + assert_noop!( + SubtensorModule::set_root_claim_type( + RuntimeOrigin::signed(coldkey), + RootClaimTypeEnum::Keep + ), + Error::::RootClaimTypeNotSupported + ); + assert_noop!( + SubtensorModule::set_root_claim_type( + RuntimeOrigin::signed(coldkey), + RootClaimTypeEnum::KeepSubnets { + subnets: BTreeSet::from([NetUid::from(1)]) + } + ), + Error::::RootClaimTypeNotSupported + ); }); } @@ -436,6 +414,109 @@ fn test_root_basket_routes_to_target_subnet() { }); } +// ============================================================================= +// Beta basket: protocol-flow accounting (symmetric) +// ============================================================================= + +/// The basket must book protocol flow symmetrically: the origin sell on A is an outflow, each +/// redistribution buy on B/C is an inflow, and the claim sell on B/C is an outflow that nets the +/// deposit-then-claim round-trip back toward zero on the dest pools. +#[test] +fn test_root_basket_records_symmetric_protocol_flow() { + new_test_ext(1).execute_with(|| { + let owner_a = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let owner_b = U256::from(2001); + let hotkey_b = U256::from(2002); + let owner_c = U256::from(3001); + let hotkey_c = U256::from(3002); + + let netuid_a = add_dynamic_network(&hotkey, &owner_a); + let netuid_b = add_dynamic_network(&hotkey_b, &owner_b); + let netuid_c = add_dynamic_network(&hotkey_c, &owner_c); + remove_owner_registration_stake(netuid_a); + fund_pool(netuid_a); + fund_pool(netuid_b); + fund_pool(netuid_c); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(netuid_b, I96F32::from_num(0)); + RootClaimableThreshold::::insert(netuid_c, I96F32::from_num(0)); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_a, + netuid_a, + 10_000_000u64.into(), + ); + + // Split the basket 50/50 across B and C (neither is the dividend origin A). + set_root_weights_direct(&hotkey, 0, &[(netuid_b, u16::MAX), (netuid_c, u16::MAX)]); + + // No protocol flow has been recorded on any subnet yet. + assert_eq!(SubnetProtocolFlow::::get(netuid_a), 0); + assert_eq!(SubnetProtocolFlow::::get(netuid_b), 0); + assert_eq!(SubnetProtocolFlow::::get(netuid_c), 0); + + SubtensorModule::distribute_emission( + netuid_a, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + let flow_a = SubnetProtocolFlow::::get(netuid_a); + let flow_b = SubnetProtocolFlow::::get(netuid_b); + let flow_c = SubnetProtocolFlow::::get(netuid_c); + + // Origin sell on A is booked as an outflow (negative); the buys on B and C as inflows. + assert!(flow_a < 0, "origin sell must be an outflow, got {flow_a}"); + assert!(flow_b > 0, "buy on B must be an inflow, got {flow_b}"); + assert!(flow_c > 0, "buy on C must be an inflow, got {flow_c}"); + + // Symmetry: every TAO sold on A is spent buying on B and C, so the inflows exactly offset + // the outflow across subnets. + assert_abs_diff_eq!(flow_b + flow_c, -flow_a, epsilon = 10i64); + + // Now redeem the basket on B and C. The claim sells alpha back to TAO, booking an outflow + // on each dest that nets the round-trip back toward zero. + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from([netuid_b, netuid_c]) + )); + + let flow_b_after = SubnetProtocolFlow::::get(netuid_b); + let flow_c_after = SubnetProtocolFlow::::get(netuid_c); + + // Claim recorded an outflow: the dest flow decreased, and the deposit+claim round-trip + // leaves a residual far smaller than the original inflow (only swap fees/slippage remain). + assert!( + flow_b_after < flow_b, + "claim must book an outflow on B: {flow_b_after} !< {flow_b}" + ); + assert!( + flow_c_after < flow_c, + "claim must book an outflow on C: {flow_c_after} !< {flow_c}" + ); + assert!( + flow_b_after.abs() < flow_b, + "round-trip residual on B should be smaller than the inflow: {flow_b_after} vs {flow_b}" + ); + assert!( + flow_c_after.abs() < flow_c, + "round-trip residual on C should be smaller than the inflow: {flow_c_after} vs {flow_c}" + ); + }); +} + // ============================================================================= // Beta basket: claiming (always full swap to root TAO) // ============================================================================= diff --git a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs index 5fab746837..537259b5c0 100644 --- a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs +++ b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs @@ -2486,10 +2486,6 @@ fn test_revert_claim_root_with_swap_hotkey() { AlphaBalance::ZERO, ); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - )); assert_ok!(SubtensorModule::claim_root( RuntimeOrigin::signed(coldkey), BTreeSet::from([netuid]) @@ -3070,10 +3066,6 @@ fn test_swap_hotkey_root_claims_changed_if_root() { AlphaBalance::ZERO, ); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(staker_coldkey), - RootClaimTypeEnum::Keep - )); assert_ok!(SubtensorModule::claim_root( RuntimeOrigin::signed(staker_coldkey), BTreeSet::from([netuid_1]) @@ -3169,10 +3161,6 @@ fn test_swap_hotkey_root_claims_changed_if_all_subnets() { AlphaBalance::ZERO, ); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(staker_coldkey), - RootClaimTypeEnum::Keep - )); assert_ok!(SubtensorModule::claim_root( RuntimeOrigin::signed(staker_coldkey), BTreeSet::from([netuid_1]) From 6acb46f0e913b9d60d620b836159bb4f7a61e0df Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 20:31:58 -0600 Subject: [PATCH 7/8] Beta basket: allow root (UID 0) as a held-TAO basket slot A validator can now weight root (uid 0) in its basket vector to opt out of subnet exposure: that slice is held as root stake (TAO at 1:1) under the escrow instead of being swapped into subnet alpha, and it compounds and is claimable through the same E/P machinery as the alpha slots. Root has no AMM pool, so the swap is elided (valuation is already 1:1) and the reserve bookkeeping is mirrored directly; the escrow custody account is excluded from the claimant base and from dissolution payouts since it is not a claimant. Adds 4 tests covering deposit, claim (reassign, no swap), compounding, and the escrow-denominator exclusion. Co-authored-by: Cursor --- pallets/subtensor/src/staking/claim_root.rs | 129 ++++++++-- pallets/subtensor/src/tests/claim_root.rs | 261 ++++++++++++++++++++ 2 files changed, 364 insertions(+), 26 deletions(-) diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index c2c7ac351c..34107e5a5f 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -95,12 +95,15 @@ impl Pallet { .map(|uid| Weights::::get(NetUidStorageIndex::ROOT, uid)) .unwrap_or_default(); - // Keep only weights that point at existing, non-root subnets. + // Keep weights that point at root (uid 0) or an existing subnet. Root is a valid + // destination: that slice is held as a root-stake (TAO) basket slot instead of being + // deployed into subnet alpha, letting a validator opt out of subnet exposure while its + // stakers still accumulate (and compound) yield on root. let valid: Vec<(NetUid, u64)> = weights .into_iter() .filter_map(|(dest, weight)| { let dest_netuid = NetUid::from(dest); - if weight > 0 && !dest_netuid.is_root() && Self::if_subnet_exist(dest_netuid) { + if weight > 0 && (dest_netuid.is_root() || Self::if_subnet_exist(dest_netuid)) { Some((dest_netuid, weight as u64)) } else { None @@ -109,7 +112,14 @@ impl Pallet { .collect(); let weight_sum: u64 = valid.iter().map(|(_, w)| *w).sum(); - let total_root = Self::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT); + 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 every slot's claimable rate would + // be diluted and a slice of principal would become unclaimable. + let total_root = Self::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT).saturating_sub( + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, NetUid::ROOT), + ); // No usable weights or no root stake to apportion against: recycle. if valid.is_empty() || weight_sum == 0 || total_root.is_zero() { @@ -118,7 +128,6 @@ impl Pallet { } let total_root_float = I96F32::saturating_from_num(total_root); - let escrow = Self::get_beta_escrow_account_id(); let outcome = with_transaction(|| { // 1. Sell the origin-subnet alpha for TAO. @@ -155,18 +164,27 @@ impl Pallet { continue; } - let bought: AlphaBalance = match Self::swap_tao_for_alpha( - *dest_netuid, - tao_s.into(), - T::SwapInterface::max_price(), - true, - ) { - Ok(res) => res.amount_paid_out, - Err(err) => return TransactionOutcome::Rollback(Err(err)), - }; + let is_root_slot = dest_netuid.is_root(); - // Record the redistribution buy as protocol inflow (TAO entered B/C/D's pool). - Self::record_protocol_inflow(*dest_netuid, tao_s.into()); + // Acquire the slot's asset for this TAO slice. Subnets buy alpha from the pool; + // root has no pool, so the slice is simply held as root stake (TAO at 1:1). + let bought: AlphaBalance = if is_root_slot { + tao_s.into() + } else { + let bought = match Self::swap_tao_for_alpha( + *dest_netuid, + tao_s.into(), + T::SwapInterface::max_price(), + true, + ) { + Ok(res) => res.amount_paid_out, + Err(err) => return TransactionOutcome::Rollback(Err(err)), + }; + // Record the redistribution buy as protocol inflow (TAO entered the pool). + // Root has no AMM pool, so it has no protocol flow to record. + Self::record_protocol_inflow(*dest_netuid, tao_s.into()); + bought + }; if bought.is_zero() { continue; @@ -176,6 +194,8 @@ impl Pallet { // already-compounded basket (E/P > 1) must mint fewer principal "shares" than the // alpha bought, so E/P is left unchanged: existing holders are not diluted and a // late staker cannot skim past compounding. shares = bought / (E/P) = bought*P/E. + // For root, `alpha_to_tao_value(ROOT) == 1:1`, so the escrow value (root stake) is + // already in TAO and the same NAV math applies unchanged. let escrow_value: u64 = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *dest_netuid) .to_u64(); @@ -197,14 +217,17 @@ impl Pallet { .checked_div(total_root_float) .unwrap_or(I96F32::saturating_from_num(0)); - // Too small to credit (shares or rate round to zero): recycle so the escrow never - // grows without matching claimable principal (keeps `Σ owed == BasketPrincipal`). + // Too small to credit (shares or rate round to zero): keep `Σ owed == principal`. + // Subnets recycle the bought alpha; the root slice was never minted into a pool, so + // it is simply dropped (already debited from the origin pool by the sell above). if shares == 0 || increment == I96F32::saturating_from_num(0) { - Self::recycle_subnet_alpha(*dest_netuid, bought); + if !is_root_slot { + Self::recycle_subnet_alpha(*dest_netuid, bought); + } continue; } - // Stake the full `bought` alpha to the validator under the escrow coldkey (grows E + // Stake the full `bought` asset to the validator under the escrow coldkey (grows E // by `bought`); P grows only by `shares`, so E/P is preserved on deposit. Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, @@ -213,6 +236,16 @@ impl Pallet { bought, ); + // For root, mirror `swap_tao_for_alpha`'s reserve bookkeeping: the TAO slice now + // lives as root stake. (Subnets already did this inside the swap.) + if is_root_slot { + SubnetTAO::::mutate(NetUid::ROOT, |t| *t = t.saturating_add(tao_s.into())); + SubnetAlphaOut::::mutate(NetUid::ROOT, |o| { + *o = o.saturating_add(tao_s.into()) + }); + TotalStake::::mutate(|t| *t = t.saturating_add(tao_s.into())); + } + // Record basket principal as NAV shares (not face alpha). BasketPrincipal::::mutate(hotkey, *dest_netuid, |p| { *p = p.saturating_add(shares.into()); @@ -344,6 +377,50 @@ impl Pallet { return Ok(()); } + if netuid.is_root() { + // Root slot: the escrow already holds the staker's claim as root stake (TAO at 1:1). + // Redemption just reassigns `payout` root stake from the escrow custody account to the + // staker — no swap, no new TAO, total root stake conserved. + with_transaction(|| { + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + NetUid::ROOT, + payout.into(), + ); + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + payout.into(), + ); + + // The staker's root stake just grew by `payout`; rebase claimed watermarks across + // all slots so this does not retroactively inflate their other baskets' claimable + // (mirrors the subnet path, which stakes the realized TAO onto root). + Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(hotkey, coldkey, payout); + + Self::deposit_event(Event::BasketClaimed { + hotkey: hotkey.clone(), + coldkey: coldkey.clone(), + netuid, + tao: payout.into(), + }); + + TransactionOutcome::Commit(Ok::<(), DispatchError>(())) + })?; + + // Consume the claimed principal from the basket and advance the watermark. + BasketPrincipal::::mutate(hotkey, netuid, |p| { + *p = p.saturating_sub(owed_principal.into()); + }); + RootClaimed::::mutate((netuid, hotkey, coldkey), |root_claimed| { + *root_claimed = root_claimed.saturating_add(owed_principal.into()); + }); + + return Ok(()); + } + with_transaction(|| { // Remove the payout alpha from the validator's basket (escrow position). Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( @@ -505,13 +582,11 @@ impl Pallet { coldkey: &T::AccountId, amount: AlphaBalance, ) { - // Iterate over all the subnets this hotkey is staked on for root. + // Iterate over all the slots this hotkey has claimable for root (including the root slot + // itself: the root-stake basket slot rebases like any other so changing root stake never + // retroactively grants or removes accrued claimable). let root_claimable = RootClaimable::::get(hotkey); for (netuid, claimable_rate) in root_claimable.iter() { - if *netuid == NetUid::ROOT.into() { - continue; // Skip the root netuid. - } - // Get current staker root claimed value. let root_claimed: u128 = RootClaimed::::get((netuid, hotkey, coldkey)); @@ -687,9 +762,11 @@ impl Pallet { tao: owed_tao.amount_paid_out, }); - // Gather this validator's root stakers and their owed basket entitlement. + // Gather this validator's root stakers and their owed basket entitlement. The escrow + // custody account is excluded: it may hold root stake (the root-stake basket slot) but + // it is custody, not a claimant, so it must not receive a liquidation payout. let coldkeys: BTreeSet = Self::alpha_iter_single_prefix(hotkey) - .filter(|(_, n, _)| *n == NetUid::ROOT) + .filter(|(ck, n, _)| *n == NetUid::ROOT && ck != escrow) .map(|(coldkey, _, _)| coldkey) .collect(); let mut owed_list: Vec<(T::AccountId, u128)> = Vec::new(); diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 65286a9464..edfdf1b0bd 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -1634,3 +1634,264 @@ fn test_root_basket_end_to_end_via_coinbase() { assert!(root_stake_of(&hotkey, &coldkey) > root_before); }); } + +// ============================================================================= +// Beta basket: root (UID 0) slot — "opt out of subnets, hold yield as root TAO" +// ============================================================================= + +/// A root-weighted (UID 0) slice is held as root stake under the escrow at 1:1, recorded as +/// basket principal, and is TotalStake-neutral (the origin sell is balanced by the root-stake +/// credit — no swap, since root has no AMM pool). +#[test] +fn test_root_basket_uid0_holds_as_root_stake() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + + // Validator opts out of subnets: 100% of the basket weight on root (UID 0). + set_root_weights_direct(&hotkey, 0, &[(NetUid::ROOT, u16::MAX)]); + + assert_eq!(escrow_alpha(&hotkey, NetUid::ROOT), 0); + assert_eq!( + u64::from(BasketPrincipal::::get(&hotkey, NetUid::ROOT)), + 0 + ); + + let ts_before = TotalStake::::get().to_u64(); + let pending_root_alpha = 1_000_000u64; + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + pending_root_alpha.into(), + AlphaBalance::ZERO, + ); + let ts_after = TotalStake::::get().to_u64(); + + // A root slot now exists: principal recorded, escrow holds root stake, claimable rate set. + let escrow_root = escrow_alpha(&hotkey, NetUid::ROOT); + let principal = u64::from(BasketPrincipal::::get(&hotkey, NetUid::ROOT)); + assert!(escrow_root > 0, "escrow must hold root stake"); + assert!(principal > 0, "root slot principal must be recorded"); + assert!(RootClaimable::::get(hotkey).contains_key(&NetUid::ROOT.into())); + + // Held at 1:1 (E/P starts at 1): escrow root stake ~= recorded principal. + assert_abs_diff_eq!(escrow_root, principal, epsilon = 10u64); + + // No subnet alpha was bought for the root slice (no subnet escrow position created). + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + + // Sell-origin then credit-to-root nets to zero: distribution is TotalStake-neutral. + assert_eq!(ts_before, ts_after, "root deposit must be TotalStake-neutral"); + }); +} + +/// Redeeming a root slot reassigns the escrow's root stake to the staker: the staker's root +/// stake grows, the escrow drains, principal is consumed, and it is TotalStake-neutral (no swap, +/// no minted TAO — total root stake is conserved, just moved between coldkeys). +#[test] +fn test_root_basket_uid0_claim_reassigns_no_swap() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(NetUid::ROOT, I96F32::from_num(0)); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(NetUid::ROOT, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + let principal_before = u64::from(BasketPrincipal::::get(&hotkey, NetUid::ROOT)); + let escrow_before = escrow_alpha(&hotkey, NetUid::ROOT); + let root_before = root_stake_of(&hotkey, &coldkey); + assert!(principal_before > 0); + assert!(escrow_before > 0); + + let ts_before = TotalStake::::get().to_u64(); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from([NetUid::ROOT]) + )); + let ts_after = TotalStake::::get().to_u64(); + + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); + let escrow_after = escrow_alpha(&hotkey, NetUid::ROOT); + + // Staker gained root stake; the escrow gave up ~the same amount (a pure reassignment). + assert!(gain > 0, "staker must accumulate root TAO"); + assert_abs_diff_eq!(gain, escrow_before.saturating_sub(escrow_after), epsilon = 10u64); + + // Principal consumed, watermark advanced, TotalStake untouched (no swap, no mint). + assert!(u64::from(BasketPrincipal::::get(&hotkey, NetUid::ROOT)) < principal_before); + assert!(RootClaimed::::get((NetUid::ROOT, &hotkey, &coldkey)) > 0); + assert_eq!(ts_before, ts_after, "root claim must be TotalStake-neutral"); + }); +} + +/// The root slot compounds like the alpha slots: if the escrow's root stake grows (root +/// dividends) after accrual, the sole staker redeems strictly MORE than recorded principal. +#[test] +fn test_root_basket_uid0_compounds() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(NetUid::ROOT, I96F32::from_num(0)); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(NetUid::ROOT, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + let principal = u64::from(BasketPrincipal::::get(&hotkey, NetUid::ROOT)); + assert!(principal > 0); + + // Simulate root dividends compounding the escrow's root stake (E grows, P fixed). + let escrow_before = escrow_alpha(&hotkey, NetUid::ROOT); + let escrow_ck = SubtensorModule::get_beta_escrow_account_id(); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &escrow_ck, + NetUid::ROOT, + 5_000_000u64.into(), + ); + assert!(escrow_alpha(&hotkey, NetUid::ROOT) > escrow_before); + + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from([NetUid::ROOT]) + )); + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); + + assert!( + gain > principal, + "compounding: realized {gain} must exceed principal {principal}" + ); + }); +} + +/// The escrow's own root stake is excluded from the claimant base, so a sole staker's claim +/// stays correct across repeated root deposits (no principal is stranded by denominator +/// dilution): after accrual the staker can drain the escrow's root slot to ~zero. +#[test] +fn test_root_basket_uid0_excludes_escrow_from_denominator() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + fund_pool(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + RootClaimableThreshold::::insert(NetUid::ROOT, I96F32::from_num(0)); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(NetUid::ROOT, u16::MAX)]); + + // Two deposits: the second runs while the escrow already holds root stake from the first. + // If the escrow's root stake were counted in the claimant base, the second deposit would + // under-credit the rate and strand principal in the escrow. + for _ in 0..2 { + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + } + + let escrow_before = escrow_alpha(&hotkey, NetUid::ROOT); + assert!(escrow_before > 0); + + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::from([NetUid::ROOT]) + )); + + // The sole real staker drains the whole root slot: no principal stranded by the escrow's + // own root holdings. + let escrow_after = escrow_alpha(&hotkey, NetUid::ROOT); + assert!( + escrow_after <= escrow_before / 1_000 + 10, + "root slot must drain to ~0; residual {escrow_after} of {escrow_before}" + ); + }); +} From f89898960cce20df6990bcd34c00ae4d13866756 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 20:53:51 -0600 Subject: [PATCH 8/8] Beta basket: allow setting root (UID 0) weight; dedup root reserve/settlement Align the set_root_weights producer with the basket consumer so a validator can actually populate a root (uid 0) slot on-chain (previously the extrinsic rejected root, leaving the new path unreachable in production). Code-quality cleanups: extract credit_root_reserves (the SubnetTAO/ SubnetAlphaOut/TotalStake triple, previously hand-mirrored in 3 places) and hoist the shared post-claim watermark advance so root and subnet claims share one settlement tail instead of duplicating it. Co-authored-by: Cursor --- pallets/subtensor/src/staking/claim_root.rs | 181 +++++++++----------- pallets/subtensor/src/subnets/weights.rs | 7 +- pallets/subtensor/src/tests/claim_root.rs | 38 ++++ 3 files changed, 122 insertions(+), 104 deletions(-) diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 34107e5a5f..fdcfc151c2 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -239,11 +239,7 @@ impl Pallet { // For root, mirror `swap_tao_for_alpha`'s reserve bookkeeping: the TAO slice now // lives as root stake. (Subnets already did this inside the swap.) if is_root_slot { - SubnetTAO::::mutate(NetUid::ROOT, |t| *t = t.saturating_add(tao_s.into())); - SubnetAlphaOut::::mutate(NetUid::ROOT, |o| { - *o = o.saturating_add(tao_s.into()) - }); - TotalStake::::mutate(|t| *t = t.saturating_add(tao_s.into())); + Self::credit_root_reserves(tao_s.into()); } // Record basket principal as NAV shares (not face alpha). @@ -409,105 +405,83 @@ impl Pallet { TransactionOutcome::Commit(Ok::<(), DispatchError>(())) })?; + } else { + with_transaction(|| { + // Remove the payout alpha from the validator's basket (escrow position). + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + netuid, + payout.into(), + ); - // Consume the claimed principal from the basket and advance the watermark. - BasketPrincipal::::mutate(hotkey, netuid, |p| { - *p = p.saturating_sub(owed_principal.into()); - }); - RootClaimed::::mutate((netuid, hotkey, coldkey), |root_claimed| { - *root_claimed = root_claimed.saturating_add(owed_principal.into()); - }); - - return Ok(()); - } + // Swap the basket alpha to TAO. + let owed_tao = match Self::swap_alpha_for_tao( + netuid, + payout.into(), + T::SwapInterface::min_price::(), + true, + ) { + Ok(owed_tao) => owed_tao, + Err(err) => { + log::error!("Error swapping basket alpha for TAO: {err:?}"); + return TransactionOutcome::Rollback(Err(err)); + } + }; - with_transaction(|| { - // Remove the payout alpha from the validator's basket (escrow position). - Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( - hotkey, - &escrow, - netuid, - payout.into(), - ); + let root_subnet_account_id = match Self::get_subnet_account_id(NetUid::ROOT) { + Some(account_id) => account_id, + None => { + return TransactionOutcome::Rollback(Err( + Error::::RootNetworkDoesNotExist.into(), + )); + } + }; - // Swap the basket alpha to TAO. - let owed_tao = match Self::swap_alpha_for_tao( - netuid, - payout.into(), - T::SwapInterface::min_price::(), - true, - ) { - Ok(owed_tao) => owed_tao, - Err(err) => { - log::error!("Error swapping basket alpha for TAO: {err:?}"); + if let Err(err) = Self::transfer_tao_from_subnet( + netuid, + &root_subnet_account_id, + owed_tao.amount_paid_out.into(), + ) { + log::error!("Error transferring root claim TAO from subnet: {err:?}"); return TransactionOutcome::Rollback(Err(err)); } - }; - - let root_subnet_account_id = match Self::get_subnet_account_id(NetUid::ROOT) { - Some(account_id) => account_id, - None => { - return TransactionOutcome::Rollback(Err( - Error::::RootNetworkDoesNotExist.into() - )); - } - }; - - if let Err(err) = Self::transfer_tao_from_subnet( - netuid, - &root_subnet_account_id, - owed_tao.amount_paid_out.into(), - ) { - log::error!("Error transferring root claim TAO from subnet: {err:?}"); - return TransactionOutcome::Rollback(Err(err)); - } - - // Record root sell as protocol outflow (reduces protocol cost). - let root_sell_tao: TaoBalance = owed_tao.amount_paid_out; - SubnetRootSellTao::::mutate(netuid, |total| { - *total = total.saturating_add(root_sell_tao); - }); - Self::record_protocol_outflow(netuid, root_sell_tao); - - Self::increase_stake_for_hotkey_and_coldkey_on_subnet( - hotkey, - coldkey, - NetUid::ROOT, - owed_tao.amount_paid_out.to_u64().into(), - ); - - // Increase root subnet SubnetTAO - SubnetTAO::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); - }); - // Increase root SubnetAlphaOut - SubnetAlphaOut::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(u64::from(owed_tao.amount_paid_out).into()); - }); + // Record root sell as protocol outflow (reduces protocol cost). + let root_sell_tao: TaoBalance = owed_tao.amount_paid_out; + SubnetRootSellTao::::mutate(netuid, |total| { + *total = total.saturating_add(root_sell_tao); + }); + Self::record_protocol_outflow(netuid, root_sell_tao); - // Increase Total Stake - TotalStake::::mutate(|total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); - }); + // Stake the realized TAO onto root for the staker and credit the root reserves. + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + owed_tao.amount_paid_out.to_u64().into(), + ); + Self::credit_root_reserves(owed_tao.amount_paid_out); - Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( - hotkey, - coldkey, - owed_tao.amount_paid_out.into(), - ); + Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( + hotkey, + coldkey, + owed_tao.amount_paid_out.into(), + ); - Self::deposit_event(Event::BasketClaimed { - hotkey: hotkey.clone(), - coldkey: coldkey.clone(), - netuid, - tao: owed_tao.amount_paid_out, - }); + Self::deposit_event(Event::BasketClaimed { + hotkey: hotkey.clone(), + coldkey: coldkey.clone(), + netuid, + tao: owed_tao.amount_paid_out, + }); - TransactionOutcome::Commit(Ok(())) - })?; + TransactionOutcome::Commit(Ok(())) + })?; + } - // Consume the claimed principal from the basket and advance the watermark. + // Consume the claimed principal from the basket and advance the watermark. Shared by both + // slot types: redemption settlement is identical once the asset side has been realized. BasketPrincipal::::mutate(hotkey, netuid, |p| { *p = p.saturating_sub(owed_principal.into()); }); @@ -746,15 +720,7 @@ impl Pallet { let tao_total: u64 = owed_tao.amount_paid_out.to_u64(); // Move the TAO onto root (aggregate); per-coldkey shares are credited below. - SubnetTAO::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); - }); - SubnetAlphaOut::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(u64::from(owed_tao.amount_paid_out).into()); - }); - TotalStake::::mutate(|total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); - }); + Self::credit_root_reserves(owed_tao.amount_paid_out); Self::deposit_event(Event::BasketLiquidated { hotkey: hotkey.clone(), @@ -841,6 +807,17 @@ impl Pallet { // Beta basket: read-only views (for RPC / dashboards) // ========================================================================= + /// 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. + fn credit_root_reserves(amount: TaoBalance) { + SubnetTAO::::mutate(NetUid::ROOT, |total| *total = total.saturating_add(amount)); + SubnetAlphaOut::::mutate(NetUid::ROOT, |total| { + *total = total.saturating_add(u64::from(amount).into()) + }); + TotalStake::::mutate(|total| *total = total.saturating_add(amount)); + } + /// Mark-to-market TAO value of `alpha` on `netuid` at the current pool price. /// This is a *marked* value (price x amount); actual redemption realizes slightly less /// due to AMM slippage. diff --git a/pallets/subtensor/src/subnets/weights.rs b/pallets/subtensor/src/subnets/weights.rs index 3c345fdc11..515b11185f 100644 --- a/pallets/subtensor/src/subnets/weights.rs +++ b/pallets/subtensor/src/subnets/weights.rs @@ -980,11 +980,14 @@ impl Pallet { // --- 7. No duplicate destination subnets. ensure!(!Self::has_duplicate_uids(&dests), Error::::DuplicateUids); - // --- 8. Every destination must be an existing, non-root subnet. + // --- 8. Every destination must be root (uid 0) or an existing subnet. Root is a valid + // basket destination: that weight slice is held as root stake (TAO) instead of being + // deployed into a subnet, letting a validator opt out of subnet exposure. This must mirror + // the consumer filter in `distribute_root_alpha_to_basket`. for dest in dests.iter() { let dest_netuid = NetUid::from(*dest); ensure!( - !dest_netuid.is_root() && Self::if_subnet_exist(dest_netuid), + dest_netuid.is_root() || Self::if_subnet_exist(dest_netuid), Error::::UidVecContainInvalidOne ); } diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index edfdf1b0bd..c71a174238 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -1211,6 +1211,44 @@ fn test_set_root_weights_stores_vector() { }); } +/// The `set_root_weights` extrinsic accepts root (uid 0) as a basket destination, so the +/// held-as-root-TAO slot is reachable through the real on-chain path (not just direct storage +/// writes). Producer validation must agree with the `distribute_root_alpha_to_basket` consumer. +#[test] +fn test_set_root_weights_accepts_root_destination() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let coldkey = U256::from(1003); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + + NetworksAdded::::insert(NetUid::ROOT, true); + SubnetworkN::::insert(NetUid::ROOT, 1); + Uids::::insert(NetUid::ROOT, hotkey, 0u16); + Keys::::insert(NetUid::ROOT, 0u16, hotkey); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); + + // A vector mixing root (uid 0) and a subnet is accepted and stored verbatim. + assert_ok!(SubtensorModule::set_root_weights( + RuntimeOrigin::signed(hotkey), + vec![u16::from(NetUid::ROOT), u16::from(netuid)], + vec![u16::MAX, u16::MAX], + 0, + )); + + let stored = Weights::::get(NetUidStorageIndex::ROOT, 0u16); + assert_eq!( + stored, + vec![(u16::from(NetUid::ROOT), u16::MAX), (u16::from(netuid), u16::MAX)] + ); + }); +} + // ============================================================================= // Claims 1-4: the staker-facing guarantees, proven directly. // =============================================================================