diff --git a/pallets/subtensor/rpc/src/lib.rs b/pallets/subtensor/rpc/src/lib.rs index e00729151f..480cb43778 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,38 @@ 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; + /// 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 { @@ -167,6 +199,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 +605,84 @@ 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()) + } + } + } + + 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 0fb24d61c2..3e06a3da09 100644 --- a/pallets/subtensor/runtime-api/src/lib.rs +++ b/pallets/subtensor/runtime-api/src/lib.rs @@ -81,4 +81,17 @@ 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; + /// 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/benchmarks.rs b/pallets/subtensor/src/benchmarks.rs index 2a08e4b933..31d3437061 100644 --- a/pallets/subtensor/src/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks.rs @@ -15,10 +15,9 @@ use sp_runtime::{ BoundedVec, Percent, traits::{BlakeTwo256, Hash}, }; -use sp_std::collections::btree_set::BTreeSet; use sp_std::vec; use substrate_fixed::types::U64F64; -use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; +use subtensor_runtime_common::{AlphaBalance, NetUid, NetUidStorageIndex, TaoBalance}; use subtensor_swap_interface::SwapHandler; #[benchmarks( @@ -1907,14 +1906,6 @@ mod pallet_benchmarks { _(RawOrigin::Signed(coldkey.clone()), netuid, hotkey.clone()); } - #[benchmark] - fn set_root_claim_type() { - let coldkey: T::AccountId = whitelisted_caller(); - - #[extrinsic_call] - _(RawOrigin::Signed(coldkey.clone()), RootClaimTypeEnum::Keep); - } - #[benchmark] fn claim_root() { let coldkey: T::AccountId = whitelisted_caller(); @@ -1957,6 +1948,16 @@ mod pallet_benchmarks { initial_total_hotkey_alpha.into(), ); + // Point the validator's basket weight vector at the subnet so the distributed root + // dividend is deposited into its fund (instead of being recycled for lack of weights). + if let Ok(root_uid) = Uids::::try_get(NetUid::ROOT, &hotkey) { + Weights::::insert( + NetUidStorageIndex::ROOT, + root_uid, + vec![(u16::from(netuid), 1u16)], + ); + } + let pending_root_alpha = 10_000_000u64; Subtensor::::distribute_emission( netuid, @@ -1966,29 +1967,25 @@ mod pallet_benchmarks { AlphaBalance::ZERO, ); - let initial_stake = - Subtensor::::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); - - assert_ok!(Subtensor::::set_root_claim_type( - RawOrigin::Signed(coldkey.clone()).into(), - RootClaimTypeEnum::Keep - )); + let initial_stake = Subtensor::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + ); #[extrinsic_call] - _(RawOrigin::Signed(coldkey.clone()), BTreeSet::from([netuid])); + _(RawOrigin::Signed(coldkey.clone())); - let new_stake = - Subtensor::::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); + let new_stake = Subtensor::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + ); + // The claim must actually pay out (strict: a no-op claim is a broken benchmark). assert!(new_stake > initial_stake); } - #[benchmark] - fn sudo_set_num_root_claims() { - #[extrinsic_call] - _(RawOrigin::Root, 40); - } - #[benchmark] fn sudo_set_root_claim_threshold() { let coldkey: T::AccountId = whitelisted_caller(); 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/root.rs b/pallets/subtensor/src/coinbase/root.rs index c61a71aa65..e475f3d166 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -210,7 +210,7 @@ impl Pallet { Error::::SubnetNotExists ); - Self::finalize_all_subnet_root_dividends(netuid); + Self::convert_subnet_basket_holdings_to_root(netuid); // --- Perform the cleanup before removing the network. Self::destroy_alpha_in_out_stakes(netuid)?; 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..9bd087bf4a 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -63,10 +63,6 @@ pub const MAX_CRV3_COMMIT_SIZE_BYTES: u32 = 5000; pub const ALPHA_MAP_BATCH_SIZE: usize = 30; -pub const MAX_NUM_ROOT_CLAIMS: u64 = 50; - -pub const MAX_SUBNET_CLAIMS: usize = 5; - pub const MAX_ROOT_CLAIM_THRESHOLD: u64 = 10_000_000; #[allow(deprecated)] @@ -100,7 +96,7 @@ pub mod pallet { use sp_core::{ConstU32, H160, H256}; use sp_runtime::traits::{Dispatchable, TrailingZeroInput}; use sp_std::collections::btree_map::BTreeMap; - use sp_std::collections::btree_set::BTreeSet; + use sp_std::collections::vec_deque::VecDeque; use sp_std::vec; use sp_std::vec::Vec; @@ -329,26 +325,9 @@ pub mod pallet { Recycle, } - /// ============================ - /// ==== Staking + Accounts ==== - /// ============================ - - #[derive( - Encode, Decode, Default, TypeInfo, Clone, PartialEq, Eq, Debug, DecodeWithMemTracking, - )] - /// Enum for the per-coldkey root claim setting. - pub enum RootClaimTypeEnum { - /// Swap any alpha emission for TAO. - #[default] - Swap, - /// Keep all alpha emission. - Keep, - /// Keep all alpha emission for specified subnets. - KeepSubnets { - /// Subnets to keep alpha emissions (swap everything else). - subnets: BTreeSet, - }, - } + // ============================ + // ==== Staking + Accounts ==== + // ============================ /// The Max Burn HalfLife Settable #[pallet::type_value] @@ -382,23 +361,6 @@ pub mod pallet { 500_000u64.into() } - /// Default root claim type. - /// This is the type of root claim that will be made. - /// This is set by the user. Either swap to TAO or keep as alpha. - #[pallet::type_value] - pub fn DefaultRootClaimType() -> RootClaimTypeEnum { - RootClaimTypeEnum::default() - } - - /// Default number of root claims per claim call. - /// Ideally this is calculated using the number of staking coldkey - /// and the block time. - #[pallet::type_value] - pub fn DefaultNumRootClaim() -> u64 { - // once per week (+ spare keys for skipped tries) - 5 - } - /// Default value for zero. #[pallet::type_value] pub fn DefaultZeroU64() -> u64 { @@ -410,6 +372,11 @@ pub mod pallet { pub fn DefaultZeroI64() -> i64 { 0 } + /// Default value for zero fixed-point I96F32. + #[pallet::type_value] + pub fn DefaultZeroI96F32() -> I96F32 { + I96F32::saturating_from_num(0) + } /// Default value for Alpha currency. #[pallet::type_value] pub fn DefaultZeroAlpha() -> AlphaBalance { @@ -2463,10 +2430,16 @@ pub mod pallet { >; #[pallet::storage] // --- MAP(netuid ) --> Root claim threshold + /// Basket redemption is fund-level (not per-subnet), so only the `NetUid::ROOT` entry is + /// consulted: a claim below `RootClaimableThreshold[ROOT]` TAO is skipped as dust. Other + /// entries are inert. pub type RootClaimableThreshold = StorageMap<_, Blake2_128Concat, NetUid, I96F32, ValueQuery, DefaultMinRootClaimAmount>; - #[pallet::storage] // --- MAP ( hot ) --> MAP(netuid ) --> claimable_dividends | Root claimable dividends. + /// --- MAP ( hot ) --> MAP(netuid ) --> claimable_dividends | LEGACY per-subnet root + /// claimable rates. Superseded by the unified [`BasketRate`]; only read (and drained) by + /// `migrate_seed_beta_basket`. Do not use in runtime logic. + #[pallet::storage] pub type RootClaimable = StorageMap< _, Blake2_128Concat, @@ -2476,7 +2449,8 @@ pub mod pallet { DefaultRootClaimable, >; - // Already claimed root alpha. + /// LEGACY per-subnet claimed watermarks. Superseded by the unified [`BasketClaimed`]; only + /// read (and drained) by `migrate_seed_beta_basket`. Do not use in runtime logic. #[pallet::storage] pub type RootClaimed = StorageNMap< _, @@ -2488,15 +2462,49 @@ pub mod pallet { u128, ValueQuery, >; - #[pallet::storage] // -- MAP ( cold ) --> root_claim_type enum - pub type RootClaimType = StorageMap< + + /// --- MAP ( validator_hotkey ) --> total outstanding basket fund shares `P`. + /// + /// A validator's beta basket is a single fund: its holdings are the escrow stake positions + /// `(hotkey, escrow, netuid)` across subnets (the root slot is the fund's TAO/cash position), + /// and its net asset value `N` is the mark-to-market TAO value of those holdings. Stakers' + /// entitlements are denominated in *fund shares*, never in any particular subnet's alpha: + /// deposits mint `tao_value_added * P / N` shares (so existing holders are not diluted) and + /// redemption pays the staker's owed share fraction `owed / P` of every holding, sold + /// pro-rata. Because entitlement is decoupled from composition, holdings can be rebalanced + /// (validator-directed trading, dissolution conversions) without touching any staker's claim. + #[pallet::storage] + pub type BasketShares = + StorageMap<_, Blake2_128Concat, T::AccountId, u64, ValueQuery, DefaultZeroU64>; + + /// --- MAP ( validator_hotkey ) --> cumulative fund-shares-per-root-stake accumulator. + /// + /// Each dividend deposit increments this by `minted_shares / total_root_stake`. A staker's + /// gross entitlement is `BasketRate * root_stake`; net owed subtracts their + /// [`BasketClaimed`] watermark. Stake additions/removals rebase the watermark by + /// `rate * delta` so changing root stake never retroactively grants or removes accrued + /// claimable. + #[pallet::storage] + pub type BasketRate = + StorageMap<_, Blake2_128Concat, T::AccountId, I96F32, ValueQuery, DefaultZeroI96F32>; + + /// --- DMAP ( validator_hotkey, staker_coldkey ) --> fund shares already claimed (watermark). + /// + /// Signed on purpose: stake-change rebasing (`claimed ± rate * delta`) must be exact in both + /// directions. With an unsigned floor, unstaking root before claiming would clip the rebase + /// at zero, silently forfeiting the staker's accrued entitlement and permanently stranding + /// the matching shares (and their escrow value) in the fund. + #[pallet::storage] + pub type BasketClaimed = StorageDoubleMap< _, Blake2_128Concat, T::AccountId, - RootClaimTypeEnum, + Blake2_128Concat, + T::AccountId, + i128, ValueQuery, - DefaultRootClaimType, >; + #[pallet::storage] // --- MAP ( u64 ) --> coldkey | Maps coldkeys that have stake to an index pub type StakingColdkeysByIndex = StorageMap<_, Identity, u64, T::AccountId, OptionQuery>; @@ -2506,8 +2514,6 @@ pub mod pallet { #[pallet::storage] // --- Value --> num_staking_coldkeys pub type NumStakingColdkeys = StorageValue<_, u64, ValueQuery, DefaultZeroU64>; - #[pallet::storage] // --- Value --> num_root_claim | Number of coldkeys to claim each auto-claim. - pub type NumRootClaim = StorageValue<_, u64, ValueQuery, DefaultNumRootClaim>; /// ============================= /// ==== EVM related storage ==== diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 7a64acba44..b34de31567 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -12,10 +12,7 @@ mod dispatches { use sp_runtime::{Percent, Saturating, traits::Hash}; use crate::MAX_CRV3_COMMIT_SIZE_BYTES; - use crate::MAX_NUM_ROOT_CLAIMS; use crate::MAX_ROOT_CLAIM_THRESHOLD; - use crate::MAX_SUBNET_CLAIMS; - /// Dispatchable functions allow users to interact with the pallet and invoke state changes. /// These functions materialize as "extrinsics", which are often compared to transactions. /// Dispatchable functions must be annotated with a weight and must return a DispatchResult. @@ -97,6 +94,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 @@ -2151,6 +2168,12 @@ mod dispatches { } /// --- Claims the root emissions for a coldkey. + /// + /// Redemption is fund-level: for every validator the coldkey stakes to, the staker's + /// owed fund shares are redeemed as their pro-rata fraction of each basket holding + /// (sold to TAO and staked on root). There is no per-subnet selection — the basket is a + /// single fund whose composition is independent of staker entitlements. + /// /// # Args: /// * 'origin': (Origin): /// - The signature of the caller's coldkey. @@ -2163,68 +2186,21 @@ mod dispatches { /// #[pallet::call_index(121)] #[pallet::weight(::WeightInfo::claim_root())] - pub fn claim_root( - origin: OriginFor, - subnets: BTreeSet, - ) -> DispatchResultWithPostInfo { + pub fn claim_root(origin: OriginFor) -> DispatchResultWithPostInfo { let coldkey: T::AccountId = ensure_signed(origin)?; - ensure!(!subnets.is_empty(), Error::::InvalidSubnetNumber); - ensure!( - subnets.len() <= MAX_SUBNET_CLAIMS, - Error::::InvalidSubnetNumber - ); - Self::maybe_add_coldkey_index(&coldkey); - let weight = Self::do_root_claim(coldkey, Some(subnets))?; + let weight = Self::do_root_claim(coldkey)?; Ok((Some(weight), Pays::Yes).into()) } - /// --- Sets the root claim type for the coldkey. - /// # Args: - /// * 'origin': (Origin): - /// - The signature of the caller's coldkey. - /// - /// # Event: - /// * RootClaimTypeSet; - /// - On the successfully setting the root claim type for the coldkey. - /// - #[pallet::call_index(122)] - #[pallet::weight(::WeightInfo::set_root_claim_type())] - pub fn set_root_claim_type( - origin: OriginFor, - new_root_claim_type: RootClaimTypeEnum, - ) -> DispatchResult { - let coldkey: T::AccountId = ensure_signed(origin)?; - - if let RootClaimTypeEnum::KeepSubnets { subnets } = &new_root_claim_type { - ensure!(!subnets.is_empty(), Error::::InvalidSubnetNumber); - } - - Self::maybe_add_coldkey_index(&coldkey); - - Self::change_root_claim_type(&coldkey, new_root_claim_type); - Ok(()) - } - - /// --- Sets root claim number (sudo extrinsic). Zero disables auto-claim. - #[pallet::call_index(123)] - #[pallet::weight(::WeightInfo::sudo_set_num_root_claims())] - pub fn sudo_set_num_root_claims(origin: OriginFor, new_value: u64) -> DispatchResult { - ensure_root(origin)?; - - ensure!( - new_value <= MAX_NUM_ROOT_CLAIMS, - Error::::InvalidNumRootClaim - ); + // Call indices 122 (`set_root_claim_type`) and 123 (`sudo_set_num_root_claims`) are + // retired: basket redemption is always a full swap to root TAO (no per-coldkey claim + // type), and there is no auto-claim scheduler to configure. Do not reuse these indices. - NumRootClaim::::set(new_value); - - Ok(()) - } - - /// --- Sets root claim threshold for subnet (sudo or owner origin). + /// --- Sets the root claim dust threshold (sudo). Basket redemption is fund-level, so + /// only the `NetUid::ROOT` entry is meaningful; other netuids are rejected. #[pallet::call_index(124)] #[pallet::weight(::WeightInfo::sudo_set_root_claim_threshold())] pub fn sudo_set_root_claim_threshold( @@ -2234,6 +2210,10 @@ mod dispatches { ) -> DispatchResult { Self::ensure_subnet_owner_or_root(origin, netuid)?; + // Claims only ever consult the ROOT entry; accepting other netuids would silently + // store an inert value. + ensure!(netuid.is_root(), Error::::InvalidRootClaimThreshold); + ensure!( new_value <= I96F32::from(MAX_ROOT_CLAIM_THRESHOLD), Error::::InvalidRootClaimThreshold diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 46343b6ed1..ce28ab78d9 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -263,12 +263,8 @@ mod errors { TrimmingWouldExceedMaxImmunePercentage, /// Violating the rules of Childkey-Parentkey consistency ChildParentInconsistency, - /// Invalid number of root claims - InvalidNumRootClaim, /// Invalid value of root claim threshold InvalidRootClaimThreshold, - /// Exceeded subnet limit number or zero. - InvalidSubnetNumber, /// The maximum allowed UIDs times mechanism count should not exceed 256. TooManyUIDsPerMechanism, /// Voting power tracking is not enabled for this subnet. diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 918baf1107..ea8118db8a 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. @@ -470,15 +472,39 @@ mod events { coldkey: T::AccountId, }, - /// Root claim type for a coldkey has been set. - /// Parameters: - /// (coldkey, u8) - RootClaimTypeSet { - /// Claim coldkey + /// A validator's beta basket (fund) received a dividend deposit: `tao` of value was + /// deployed across subnets per the validator's weight vector, minting `shares` fund + /// shares at the pre-deposit NAV. + BasketDeposited { + /// Validator hotkey whose basket received the deposit. + hotkey: T::AccountId, + /// TAO value added to the fund (marked at moving prices). + tao: TaoBalance, + /// Fund shares minted at the pre-deposit NAV (grows `BasketShares`). + shares: u64, + }, + + /// A staker redeemed (claimed) their owed share of a validator's beta basket: their + /// pro-rata fraction of every holding was realized as `tao` and staked onto their root + /// position. + BasketClaimed { + /// Validator hotkey the basket belongs to. + hotkey: T::AccountId, + /// Staker coldkey that claimed. coldkey: T::AccountId, + /// TAO realized and staked on root for the staker. + tao: TaoBalance, + }, - /// Claim type - root_claim_type: RootClaimTypeEnum, + /// A validator's basket holding on a dissolving subnet was converted into the fund's + /// root (TAO) slot. Fund shares and staker entitlements are unaffected. + BasketHoldingConverted { + /// Validator hotkey whose holding was converted. + hotkey: T::AccountId, + /// Subnet being dissolved. + netuid: NetUid, + /// TAO realized and held as the fund's root-slot position. + tao: TaoBalance, }, /// Voting power tracking has been enabled for a subnet. diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 869d6074da..d2e96486f7 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -175,7 +175,10 @@ 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 unified beta-basket fund from legacy per-subnet claim state (v2: + // fresh key so chains that ran the superseded per-slot v1 seed still convert). + .saturating_add(migrations::migrate_seed_beta_basket::migrate_seed_beta_basket_v2::()); 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..c4ae80f7ee --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_seed_beta_basket.rs @@ -0,0 +1,251 @@ +use super::*; +use frame_support::pallet_prelude::{Blake2_128Concat, Identity, ValueQuery}; +use frame_support::storage_alias; +use frame_support::weights::Weight; +use scale_info::prelude::string::String; +use sp_std::collections::btree_map::BTreeMap; +use substrate_fixed::types::{I96F32, U96F32}; +use subtensor_runtime_common::{AlphaBalance, NetUid}; +use subtensor_swap_interface::SwapHandler; + +pub mod deprecated { + use super::*; + + /// Per-slot outstanding basket principal written by the superseded v1 seed migration + /// (`migrate_seed_beta_basket`) and the intermediate per-subnet-slot runtime. No longer + /// declared in the pallet; v2 clears any orphaned entries. + #[storage_alias] + pub type BasketPrincipal = StorageDoubleMap< + Pallet, + Blake2_128Concat, + AccountIdOf, + Identity, + NetUid, + AlphaBalance, + ValueQuery, + >; +} + +/// Seeds the unified beta-basket fund from pre-existing per-subnet claim state. +/// +/// Legacy model: a validator's root dividends accrued as a per-subnet *rate* +/// (`RootClaimable[hotkey][netuid]`, alpha-per-root-stake) with per-subnet claimed watermarks +/// (`RootClaimed[(netuid, hotkey, coldkey)]`), backed by unattributed outstanding alpha in +/// `SubnetAlphaOut`. The beta basket instead is a single *fund* per validator: escrow stake +/// positions `(hotkey, escrow, netuid)` are its holdings, `BasketShares` its outstanding +/// TAO-denominated shares `P`, `BasketRate` the single shares-per-root-stake accumulator, and +/// `BasketClaimed[(hotkey, coldkey)]` the per-staker watermark. +/// +/// Conversion fixes each subnet's moving price `p_s` at the migration block (spot fallback for +/// cold EMAs; 1:1 for the root slot) and re-denominates every legacy alpha-unit quantity into +/// TAO-valued fund shares: +/// +/// * holdings: the still-outstanding legacy alpha `remaining_s = rate_s * total_root - Σ claimed` +/// is attributed to the validator under the escrow coldkey on subnet `s`; +/// * `BasketRate[hot] = Σ_s rate_s * p_s` +/// * `BasketShares[hot] = Σ_s remaining_s * p_s` +/// * `BasketClaimed[hot, ck] = Σ_s claimed_s(ck) * p_s` +/// +/// With NAV marked at the same `p_s`, `N == P` at the seed, and every staker's owed TAO value is +/// preserved exactly: `owed_new = Σ_s p_s (rate_s * stake - claimed_s)`. The drained legacy maps +/// are cleared so no per-subnet claim state survives. +/// +/// ## Chains that already ran the superseded v1 seed migration +/// +/// This is **v2** under a fresh `HasMigrationRun` key: the v1 migration +/// (`"migrate_seed_beta_basket"`) seeded the abandoned per-slot `BasketPrincipal` model on dev +/// and test chains, consuming the old key. Reusing the old name would silently skip this +/// migration there and strand every basket. v2 therefore also tolerates v1 state: +/// +/// * escrow positions may already exist (v1 staked `remaining` at its run block, and the +/// intermediate runtime compounded/claimed against them). The escrow is only topped up when +/// it holds *less* than the recomputed `remaining`; when it holds more (compounding), the +/// surplus stays and simply carries the old slot's `E/P` multiplier into the fund's `N/P`. +/// * legacy `RootClaimable` may contain root-slot (netuid 0) entries created by the +/// intermediate runtime. These convert at price 1, but never mint a top-up (root has no pool +/// to attribute from); their share contribution is capped at the escrow's actual root stake +/// so shares are never unbacked. +/// * orphaned `BasketPrincipal` entries are cleared. +/// +/// 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_v2() -> Weight { + let migration_name = b"migrate_seed_beta_basket_v2".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).saturating_sub( + // On a v1 chain the escrow may already hold a root-slot position; it is custody, + // not a claimant, so it is excluded from the claimant base like everywhere else. + Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + NetUid::ROOT, + ), + ), + ); + weight.saturating_accrue(T::DbWeight::get().reads(2)); + + let claimable = RootClaimable::::take(hotkey); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + + let mut fund_rate: I96F32 = I96F32::saturating_from_num(0); + let mut fund_shares: u64 = 0; + let mut fund_claimed: BTreeMap = BTreeMap::new(); + + for (netuid, rate) in claimable.iter() { + // Fixed conversion price for this subnet: the moving/EMA price (manipulation + // resistant), falling back to spot if the EMA has not warmed up yet so legacy + // claims never convert to zero shares on a young subnet. Root converts 1:1. + let price: U96F32 = if netuid.is_root() { + U96F32::saturating_from_num(1) + } else { + let moving: U96F32 = + U96F32::saturating_from_num(Pallet::::get_moving_alpha_price(*netuid)); + if moving > U96F32::saturating_from_num(0) { + moving + } else { + U96F32::saturating_from_num(T::SwapInterface::current_alpha_price( + (*netuid).into(), + )) + } + }; + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + // Gross credited principal (alpha) = rate * total_root_stake. + let gross: I96F32 = rate.saturating_mul(total_root); + + // Total already claimed by all coldkeys on this (netuid, hotkey), converting each + // coldkey's watermark to TAO-valued fund shares while we scan. + let mut claimed_sum: I96F32 = I96F32::saturating_from_num(0); + for (coldkey, claimed) in RootClaimed::::drain_prefix((*netuid, hotkey)) { + claimed_sum = claimed_sum.saturating_add(I96F32::saturating_from_num(claimed)); + let claimed_shares: i128 = U96F32::saturating_from_num(claimed) + .saturating_mul(price) + .saturating_to_num::(); + fund_claimed + .entry(coldkey) + .and_modify(|c| *c = c.saturating_add(claimed_shares)) + .or_insert(claimed_shares); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + } + + // Remaining unclaimed (still-outstanding) principal, in alpha. + let remaining_f: I96F32 = gross.saturating_sub(claimed_sum); + let mut remaining: u64 = if remaining_f.is_negative() { + 0 + } else { + remaining_f.saturating_to_num::() + }; + + // Unified rate contribution: the legacy alpha-rate re-denominated to shares at p_s. + // (May be haircut below for an underbacked root slot.) + let mut rate_contribution: I96F32 = + rate.saturating_mul(I96F32::saturating_from_num(price)); + + let existing: u64 = + Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *netuid) + .to_u64(); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + if netuid.is_root() { + // Root-slot entries only exist on v1 chains. Root has no pool to attribute + // unbacked alpha from, so the share contribution is capped at the escrow's + // actual root stake (never top up, never mint unbacked shares). + let capped = remaining.min(existing); + if capped < remaining { + // Underbacked (degenerate v1 state): haircut the rate so `Σ owed == P` + // still holds — solve `rate_eff * total_root - claimed_sum == capped`, + // spreading the shortfall pro-rata by stake. + rate_contribution = I96F32::saturating_from_num(capped) + .saturating_add(claimed_sum) + .checked_div(total_root) + .unwrap_or(I96F32::saturating_from_num(0)); + } + remaining = capped; + } else if remaining > 0 && existing < remaining { + // Attribute the still-unattributed outstanding alpha to the validator under the + // escrow coldkey. On a fresh (mainnet) chain `existing == 0` and this stakes the + // full `remaining`; on a v1 chain it only tops up any shortfall, and a + // compounded surplus (`existing > remaining`) is left in place so the old slot's + // `E/P` multiplier carries into the fund's `N/P`. + Pallet::::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + *netuid, + AlphaBalance::from(remaining.saturating_sub(existing)), + ); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } + + fund_rate = fund_rate.saturating_add(rate_contribution); + + if remaining == 0 { + continue; + } + + // Outstanding fund shares: TAO value of the remaining alpha at p_s. + fund_shares = fund_shares.saturating_add( + U96F32::saturating_from_num(remaining) + .saturating_mul(price) + .saturating_to_num::(), + ); + seeded_slots = seeded_slots.saturating_add(1); + } + + if fund_rate != I96F32::saturating_from_num(0) { + BasketRate::::insert(hotkey, fund_rate); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } + if fund_shares != 0 { + BasketShares::::insert(hotkey, fund_shares); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } + for (coldkey, claimed) in fund_claimed { + if claimed != 0 { + BasketClaimed::::insert(hotkey, coldkey, claimed); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + } + } + } + + // Clear per-slot principal orphaned by the superseded v1 migration (no-op on fresh chains). + let principal_removal = deprecated::BasketPrincipal::::clear(u32::MAX, None); + weight.saturating_accrue(T::DbWeight::get().reads_writes( + principal_removal.loops as u64, + principal_removal.backend as u64, + )); + + HasMigrationRun::::insert(&migration_name, true); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + + log::info!( + "Migration 'migrate_seed_beta_basket_v2' completed. Seeded {seeded_slots} slots, cleared {} orphaned BasketPrincipal entries.", + principal_removal.backend + ); + + 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..7d492be1f5 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -4,464 +4,727 @@ use frame_support::storage::{TransactionOutcome, with_transaction}; use frame_support::weights::Weight; use sp_core::Get; use sp_runtime::DispatchError; -use sp_std::collections::btree_set::BTreeSet; -use substrate_fixed::types::I96F32; +use sp_runtime::traits::AccountIdConversion; +use substrate_fixed::types::{I96F32, U96F32}; +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() + /// The single global escrow coldkey that custodies every validator's beta basket. + /// + /// A validator's basket (fund) holdings are positions `(validator_hotkey, this_account, + /// netuid)` in the normal alpha share pool, so they count toward each validator's stake and + /// compound with that validator's dividends, while the account itself stays inert (no user + /// controls it). A single global coldkey is used deliberately: positions stay distinct per + /// validator via the hotkey key, and hotkey swaps migrate them by value automatically. + pub fn get_beta_escrow_account_id() -> T::AccountId { + T::SubtensorPalletId::get().into_sub_account_truncating(b"beta/esc") } - pub fn increase_root_claimable_for_hotkey_and_subnet( + /// A validator's basket holdings: every `(netuid, alpha)` position the escrow custodies for + /// this hotkey, including the root slot (the fund's TAO/cash position, valued 1:1). + pub fn get_basket_holdings(hotkey: &T::AccountId) -> Vec<(NetUid, AlphaBalance)> { + let escrow = Self::get_beta_escrow_account_id(); + Self::alpha_iter_prefix((hotkey, &escrow)) + .map(|(netuid, _)| { + ( + netuid, + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, netuid), + ) + }) + .filter(|(_, alpha)| !alpha.is_zero()) + .collect() + } + + /// 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 (a + /// root-destination slice is held directly as the fund's root-stake cash position). The + /// deposit then mints *fund shares* against the whole basket: `shares = value_added * P / N`, + /// where `N` is the fund's pre-deposit mark-to-market NAV and `P` the outstanding shares, + /// so existing holders are never diluted and a late deposit cannot skim past + /// compounding. Stakers accrue entitlement through the single per-validator + /// `BasketRate += shares / total_root_stake` accumulator; no entitlement is ever denominated + /// in a particular subnet's alpha, which is what allows holdings to be rebalanced without + /// touching staker claims. + /// + /// The whole operation is transactional: if any swap fails (or the deposit is dust), 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_for_hotkey`). + pub fn distribute_root_alpha_to_basket( hotkey: &T::AccountId, - netuid: NetUid, - amount: AlphaBalance, + origin_netuid: NetUid, + root_alpha: AlphaBalance, ) { - // Get total stake on this hotkey on root. - let total: I96F32 = - I96F32::saturating_from_num(Self::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT)); + if root_alpha.is_zero() { + return; + } - // Get increment - let increment: I96F32 = I96F32::saturating_from_num(amount) - .checked_div(total) - .unwrap_or(I96F32::saturating_from_num(0.0)); + // 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 weights that point at root (uid 0) or an existing subnet. Root is a valid + // destination: that slice is held as the fund's root-stake (TAO) cash position 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)) { + Some((dest_netuid, weight as u64)) + } else { + None + } + }) + .collect(); + + let weight_sum: u64 = valid.iter().map(|(_, w)| *w).sum(); + let escrow = Self::get_beta_escrow_account_id(); - // Unlikely to happen. This is mostly for test environment sanity checks. - if u64::from(amount) > total.saturating_to_num::() { - log::warn!("Not enough root stake. NetUID = {netuid}"); + // Claimant base = real stakers' root stake. The escrow custody account is not a claimant, + // so its own root-slot holdings are excluded; otherwise the fund's claimable rate would + // be diluted and a slice of shares would become unclaimable. + 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), + ); - let owner = Owner::::get(hotkey); - Self::increase_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &owner, netuid, amount); + // 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; } - // Increment claimable for this subnet. - RootClaimable::::mutate(hotkey, |claimable| { - claimable - .entry(netuid) - .and_modify(|claim_total| *claim_total = claim_total.saturating_add(increment)) - .or_insert(increment); + let total_root_float = I96F32::saturating_from_num(total_root); + + let outcome = with_transaction(|| { + let shares_outstanding: u64 = BasketShares::::get(hotkey); + + // 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)), + }; + + // Record the origin-subnet root sell as protocol outflow (TAO left A's pool). + Self::record_protocol_outflow(origin_netuid, tao_total); + + // Pre-deposit NAV, snapshotted AFTER the origin sell and before the buys: the fund + // may itself hold origin-subnet alpha, and the sell moves that price, so marking N + // any earlier would misprice the mint against the state the deposit actually enters. + let nav_before: u64 = Self::get_validator_basket_nav_tao(hotkey).to_u64(); + + // 2. Split the TAO across subnets per w, buying each subnet's alpha into the escrow. + // `value_added` is the TAO actually deployed into the fund (standard vault + // convention: cash in at NAV); the buys' slippage is then borne by the whole fund + // pro-rata, exactly like any other mark-to-market move. + let tao_total_u64: u64 = tao_total.to_u64(); + let mut spent: u64 = 0; + let mut value_added: 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; + } + + if dest_netuid.is_root() { + // Root slot: held as root stake (TAO at 1:1), no pool to buy from. Mirror + // `swap_tao_for_alpha`'s reserve bookkeeping by hand. + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + NetUid::ROOT, + tao_s.into(), + ); + Self::credit_root_reserves(tao_s.into()); + value_added = value_added.saturating_add(tao_s); + } 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). + Self::record_protocol_inflow(*dest_netuid, tao_s.into()); + if bought.is_zero() { + continue; + } + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &escrow, + *dest_netuid, + bought, + ); + value_added = value_added.saturating_add(tao_s); + } + } + + // 3. Mint fund shares at the pre-deposit NAV: shares = value_added * P / N. A deposit + // into an already-compounded fund (N/P > 1) mints fewer shares than TAO added, so N/P + // is left unchanged. First deposit mints at par. u128 arithmetic: the u64*u64 product + // can exceed U96F32's 96 integer bits at chain-scale magnitudes, which would silently + // saturate the mint. + let shares: u64 = if shares_outstanding == 0 || nav_before == 0 { + value_added + } else { + u128::from(value_added) + .saturating_mul(u128::from(shares_outstanding)) + .checked_div(u128::from(nav_before)) + .unwrap_or(0) + .min(u128::from(u64::MAX)) as u64 + }; + + // Per-staker claimable rate increment: fund 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)); + + // Dust deposit (shares or rate round to zero): roll everything back and recycle, so + // `Σ owed == BasketShares` is never broken by uncredited value. + if shares == 0 || increment == I96F32::saturating_from_num(0) { + return TransactionOutcome::Rollback(Err(DispatchError::Other( + "basket deposit too small", + ))); + } + + BasketShares::::mutate(hotkey, |p| *p = p.saturating_add(shares)); + BasketRate::::mutate(hotkey, |rate| *rate = rate.saturating_add(increment)); + + Self::deposit_event(Event::BasketDeposited { + hotkey: hotkey.clone(), + tao: value_added.into(), + shares, + }); + + 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, - netuid: NetUid, - ) -> I96F32 { - // Get this keys stake balance on root. + /// A staker's gross *fund-share* entitlement on a validator: `BasketRate * root_stake`. + /// Shares, not TAO — convert with `basket_payout_from` / `get_basket_payout_tao`. + pub fn get_basket_claimable_shares(hotkey: &T::AccountId, coldkey: &T::AccountId) -> I96F32 { let root_stake: I96F32 = I96F32::saturating_from_num( Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT), ); - - // Get the total claimable_rate for this hotkey and this network - let claimable_rate: I96F32 = *RootClaimable::::get(hotkey) - .get(&netuid) - .unwrap_or(&I96F32::from(0)); - - // Compute the proportion owed to this coldkey via balance. - let claimable: I96F32 = claimable_rate.saturating_mul(root_stake); - - claimable + BasketRate::::get(hotkey).saturating_mul(root_stake) } - pub fn get_root_owed_for_hotkey_coldkey_float( - hotkey: &T::AccountId, - coldkey: &T::AccountId, - netuid: NetUid, - ) -> I96F32 { - let claimable = Self::get_root_claimable_for_hotkey_coldkey(hotkey, coldkey, netuid); - - // Attain the root claimed to avoid overclaiming. - let root_claimed: I96F32 = - I96F32::saturating_from_num(RootClaimed::::get((netuid, hotkey, coldkey))); + fn get_basket_owed_shares_float(hotkey: &T::AccountId, coldkey: &T::AccountId) -> I96F32 { + let claimable = Self::get_basket_claimable_shares(hotkey, coldkey); - // Subtract the already claimed alpha. - let owed: I96F32 = claimable.saturating_sub(root_claimed); + // Subtract the already-claimed watermark (signed: unstake rebasing can push it below + // zero) to avoid over- or under-claiming. + let claimed: I96F32 = I96F32::saturating_from_num(BasketClaimed::::get(hotkey, coldkey)); - owed + claimable.saturating_sub(claimed) } - pub fn get_root_owed_for_hotkey_coldkey( - hotkey: &T::AccountId, - coldkey: &T::AccountId, - netuid: NetUid, - ) -> u64 { - let owed = Self::get_root_owed_for_hotkey_coldkey_float(hotkey, coldkey, netuid); - - // Convert owed to u64, mapping negative values to 0 - let owed_u64: u64 = if owed.is_negative() { + /// A staker's net owed *fund shares* on a validator (floored at zero). Shares, not TAO. + pub fn get_basket_owed_shares(hotkey: &T::AccountId, coldkey: &T::AccountId) -> u64 { + let owed = Self::get_basket_owed_shares_float(hotkey, coldkey); + if owed.is_negative() { 0 } else { owed.saturating_to_num::() - }; - - owed_u64 + } } - pub fn root_claim_on_subnet( + /// Claims (redeems) a staker's share of a validator's beta basket. + /// + /// Redemption is fund-level and purely proportional: the staker's owed shares define a + /// fraction `f = owed / P` of the fund, and exactly that fraction of *every* holding is + /// redeemed — subnet alpha is sold to TAO (the staker bears slippage), the root-slot portion + /// is reassigned as root stake directly (no swap). Because every claim preserves the fund's + /// composition, claims and (future) validator-directed rebalancing never interfere. All + /// realized TAO is staked on root for the staker. + pub fn root_claim_for_hotkey( hotkey: &T::AccountId, coldkey: &T::AccountId, - netuid: NetUid, - root_claim_type: RootClaimTypeEnum, ignore_minimum_condition: bool, ) -> DispatchResult { - // Subtract the root 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:?} " - ); + let owed_shares: u64 = Self::get_basket_owed_shares(hotkey, coldkey); + if owed_shares == 0 { return Ok(()); // no-op } - // Convert owed to u64, mapping negative values to 0 - let owed_u64: u64 = if owed.is_negative() { - 0 - } else { - owed.saturating_to_num::() - }; + let shares_total: u64 = BasketShares::::get(hotkey); + // Nothing realizable yet (fund drained); leave the watermark untouched so the claim can + // pay out once the fund has value again. + if shares_total == 0 { + return Ok(()); + } + // A claim can never redeem more than the outstanding fund. + let owed_shares = owed_shares.min(shares_total); - if owed_u64 == 0 { + // Dust check against the estimated payout (owed fraction of the marked NAV). + let nav = Self::get_validator_basket_nav_tao(hotkey).to_u64(); + let estimated_payout: u64 = Self::basket_payout_from(owed_shares, nav, shares_total); + if !ignore_minimum_condition + && I96F32::saturating_from_num(estimated_payout) + < RootClaimableThreshold::::get(NetUid::ROOT) + { log::debug!( - "root claim on subnet {netuid} is skipped: {owed:?} for h={hotkey:?},c={coldkey:?}" + "root claim skipped (below threshold): payout={estimated_payout:?} h={hotkey:?} c={coldkey:?}" ); return Ok(()); // no-op } + if estimated_payout == 0 { + return Ok(()); + } - let swap = match root_claim_type { - RootClaimTypeEnum::Swap => true, - RootClaimTypeEnum::Keep => false, - RootClaimTypeEnum::KeepSubnets { subnets } => !subnets.contains(&netuid), - }; - - 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:?}"); - - 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)); + let escrow = Self::get_beta_escrow_account_id(); + let holdings = Self::get_basket_holdings(hotkey); + + with_transaction(|| { + // TAO credited to the staker's root stake, split by source: the root-slot portion is + // a stake reassignment (no new TAO on root), while subnet sells realize new TAO that + // must also be credited to the root reserves. + let mut root_slot_tao: u64 = 0; + let mut swapped_tao: u64 = 0; + + for (netuid, slot_alpha) in holdings.iter() { + // This staker's pro-rata slice of the holding: slot_alpha * owed / P. + let take: u64 = (u128::from(slot_alpha.to_u64())) + .saturating_mul(u128::from(owed_shares)) + .checked_div(u128::from(shares_total)) + .unwrap_or(0) as u64; + if take == 0 { + continue; } - // 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( + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( hotkey, - coldkey, - NetUid::ROOT, - owed_tao.amount_paid_out.to_u64().into(), + &escrow, + *netuid, + take.into(), ); - // Increase root subnet SubnetTAO - SubnetTAO::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); - }); + if netuid.is_root() { + // Root slot: already TAO (1:1), just reassign custody escrow -> staker below. + root_slot_tao = root_slot_tao.saturating_add(take); + continue; + } - // Increase root SubnetAlphaOut - SubnetAlphaOut::::mutate(NetUid::ROOT, |total| { - *total = total.saturating_add(u64::from(owed_tao.amount_paid_out).into()); - }); + // Sell the slice to TAO. + let tao = match Self::sell_basket_alpha_for_root_tao(*netuid, take.into()) { + Ok(tao) => tao, + Err(err) => return TransactionOutcome::Rollback(Err(err)), + }; - // Increase Total Stake - TotalStake::::mutate(|total| { - *total = total.saturating_add(owed_tao.amount_paid_out.into()); + // Record root sell (reduces protocol cost). + SubnetRootSellTao::::mutate(*netuid, |total| { + *total = total.saturating_add(tao); }); - Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( - hotkey, - coldkey, - owed_tao.amount_paid_out.into(), - ); + swapped_tao = swapped_tao.saturating_add(tao.to_u64()); + } - TransactionOutcome::Commit(Ok(())) - })?; - } else - /* Keep */ - { - // Increase the stake with the alpha owned + let total_tao: u64 = root_slot_tao.saturating_add(swapped_tao); + + // Nothing was actually realized (every per-holding take floored to zero, or the + // swaps returned zero TAO). The marked estimate above can be positive while the raw + // alpha takes floor to zero (high-price, tiny-alpha holdings), so this must NOT + // settle: roll back and leave the watermark untouched, otherwise the staker's owed + // shares would be burned for a zero payout. + if total_tao == 0 { + return TransactionOutcome::Rollback(Ok(())); + } + + // Stake the redeemed TAO on root for the staker. Only the swapped portion is new TAO + // on root (the root-slot portion was already counted in the root reserves). Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, coldkey, - netuid, - owed_u64.into(), + NetUid::ROOT, + total_tao.into(), ); - } + if swapped_tao > 0 { + Self::credit_root_reserves(swapped_tao.into()); + } - // Increase root claimed by owed amount. - RootClaimed::::mutate((netuid, hotkey, coldkey), |root_claimed| { - *root_claimed = root_claimed.saturating_add(owed_u64.into()); - }); + // The staker's root stake just grew; rebase their claimed watermark so the new stake + // does not retroactively inflate their claimable. + Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(hotkey, coldkey, total_tao); + + // Consume the claimed shares and advance the watermark. + BasketShares::::mutate(hotkey, |p| *p = p.saturating_sub(owed_shares)); + BasketClaimed::::mutate(hotkey, coldkey, |claimed| { + *claimed = claimed.saturating_add(i128::from(owed_shares)); + }); + + Self::deposit_event(Event::BasketClaimed { + hotkey: hotkey.clone(), + coldkey: coldkey.clone(), + tao: total_tao.into(), + }); + + TransactionOutcome::Commit(Ok::<(), DispatchError>(())) + })?; Ok(()) } - fn root_claim_on_subnet_weight(_root_claim_type: RootClaimTypeEnum) -> Weight { - Weight::from_parts(60_000_000, 6987) - .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) + fn root_claim_weight(num_holdings: u64) -> Weight { + // Per-holding: escrow stake read/write + swap + protocol-flow bookkeeping. + Weight::from_parts(20_000_000, 3000) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + .saturating_mul(num_holdings.max(1)) + .saturating_add(T::DbWeight::get().reads_writes(4_u64, 3_u64)) } - pub fn root_claim_all( - hotkey: &T::AccountId, - coldkey: &T::AccountId, - subnets: Option>, - ) -> Result { + + pub fn do_root_claim(coldkey: T::AccountId) -> Result { + with_transaction(|| match Self::try_do_root_claim(coldkey) { + Ok(weight) => TransactionOutcome::Commit(Ok(weight)), + Err(err) => TransactionOutcome::Rollback(Err(err)), + }) + } + + fn try_do_root_claim(coldkey: T::AccountId) -> Result { let mut weight = Weight::default(); - let root_claim_type = RootClaimType::::get(coldkey); + let hotkeys = StakingHotkeys::::get(&coldkey); weight.saturating_accrue(T::DbWeight::get().reads(1)); - // Iterate over all the subnets this hotkey has claimable for root. - let root_claimable = RootClaimable::::get(hotkey); - weight.saturating_accrue(T::DbWeight::get().reads(1)); + for hotkey in hotkeys.iter() { + let num_holdings = Self::get_basket_holdings(hotkey).len() as u64; + Self::root_claim_for_hotkey(hotkey, &coldkey, false)?; + weight.saturating_accrue(Self::root_claim_weight(num_holdings)); + } - for (netuid, _) in root_claimable.iter() { - let skip = subnets - .as_ref() - .map(|subnets| !subnets.contains(netuid)) - .unwrap_or(false); + Self::deposit_event(Event::RootClaimed { coldkey }); - if skip { - continue; - } + Ok(weight) + } - Self::root_claim_on_subnet(hotkey, coldkey, *netuid, root_claim_type.clone(), false)?; - weight.saturating_accrue(Self::root_claim_on_subnet_weight(root_claim_type.clone())); + pub fn maybe_add_coldkey_index(coldkey: &T::AccountId) { + if !StakingColdkeys::::contains_key(coldkey) { + let n = NumStakingColdkeys::::get(); + StakingColdkeysByIndex::::insert(n, coldkey.clone()); + StakingColdkeys::::insert(coldkey.clone(), n); + NumStakingColdkeys::::mutate(|n| *n = n.saturating_add(1)); } + } - Ok(weight) + /// Rebase a staker's claimed watermark by `rate * stake_delta` after their root stake + /// changed, so a stake change never retroactively grants or destroys accrued claimable. + /// The watermark is signed and may legitimately go negative (e.g. claim, then unstake). + fn rebase_basket_claimed_for_stake_delta( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + stake_delta: i128, + ) { + let rate = BasketRate::::get(hotkey); + if rate == I96F32::saturating_from_num(0) { + return; + } + BasketClaimed::::mutate(hotkey, coldkey, |claimed| { + *claimed = claimed.saturating_add( + rate.saturating_mul(I96F32::saturating_from_num(stake_delta)) + .saturating_to_num::(), + ); + }); } + /// Watermark rebase for a root-stake increase of `amount`. pub fn add_stake_adjust_root_claimed_for_hotkey_and_coldkey( hotkey: &T::AccountId, coldkey: &T::AccountId, amount: u64, ) { - // Iterate over all the subnets this hotkey is staked on for root. - let root_claimable = RootClaimable::::get(hotkey); - for (netuid, claimable_rate) in root_claimable.iter() { - // Get current staker root claimed value. - let root_claimed: u128 = RootClaimed::::get((netuid, hotkey, coldkey)); - - // Increase root claimed based on the claimable rate. - let new_root_claimed = root_claimed.saturating_add( - claimable_rate - .saturating_mul(I96F32::from(u64::from(amount))) - .saturating_to_num(), - ); - - // Set the new root claimed value. - RootClaimed::::insert((netuid, hotkey, coldkey), new_root_claimed); - } + Self::rebase_basket_claimed_for_stake_delta(hotkey, coldkey, i128::from(amount)); } + /// Watermark rebase for a root-stake decrease of `amount`. pub fn remove_stake_adjust_root_claimed_for_hotkey_and_coldkey( hotkey: &T::AccountId, coldkey: &T::AccountId, amount: AlphaBalance, ) { - // Iterate over all the subnets this hotkey is staked on for root. - 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)); - - // Decrease root claimed based on the claimable rate. - let new_root_claimed = root_claimed.saturating_sub( - claimable_rate - .saturating_mul(I96F32::from(u64::from(amount))) - .saturating_to_num(), - ); - - // Set the new root_claimed value. - RootClaimed::::insert((netuid, hotkey, coldkey), new_root_claimed); - } + Self::rebase_basket_claimed_for_stake_delta( + hotkey, + coldkey, + i128::from(u64::from(amount)).saturating_neg(), + ); } - pub fn do_root_claim( - coldkey: T::AccountId, - subnets: Option>, - ) -> Result { - with_transaction(|| match Self::try_do_root_claim(coldkey, subnets) { - Ok(weight) => TransactionOutcome::Commit(Ok(weight)), - Err(err) => TransactionOutcome::Rollback(Err(err)), - }) + /// Moves a staker's claimed watermark on `hotkey` to a new coldkey (used by coldkey swaps; + /// hotkey swaps migrate all watermarks via `transfer_basket_for_new_hotkey`). + pub fn transfer_basket_claimed_for_new_coldkey( + hotkey: &T::AccountId, + old_coldkey: &T::AccountId, + new_coldkey: &T::AccountId, + ) { + let old_claimed: i128 = BasketClaimed::::take(hotkey, old_coldkey); + if old_claimed != 0 { + BasketClaimed::::mutate(hotkey, new_coldkey, |claimed| { + *claimed = claimed.saturating_add(old_claimed); + }); + } } - fn try_do_root_claim( - coldkey: T::AccountId, - subnets: Option>, - ) -> Result { - let mut weight = Weight::default(); - - let hotkeys = StakingHotkeys::::get(&coldkey); - weight.saturating_accrue(T::DbWeight::get().reads(1)); + /// Migrates a validator's entire fund to a new hotkey: shares, rate, per-coldkey watermarks, + /// and every escrow holding, moved by value. The caller must guarantee the new hotkey is + /// clean on root (enforced by `do_swap_hotkey`), so this is a move, not a merge. + pub fn transfer_basket_for_new_hotkey(old_hotkey: &T::AccountId, new_hotkey: &T::AccountId) { + let shares = BasketShares::::take(old_hotkey); + if shares != 0 { + BasketShares::::mutate(new_hotkey, |p| *p = p.saturating_add(shares)); + } - for hotkey in hotkeys.iter() { - weight.saturating_accrue(T::DbWeight::get().reads(1)); - weight.saturating_accrue(Self::root_claim_all(hotkey, &coldkey, subnets.clone())?); + let rate = BasketRate::::take(old_hotkey); + if rate != I96F32::saturating_from_num(0) { + BasketRate::::mutate(new_hotkey, |r| *r = r.saturating_add(rate)); } - Self::deposit_event(Event::RootClaimed { coldkey }); + let claimed_entries: Vec<(T::AccountId, i128)> = + BasketClaimed::::drain_prefix(old_hotkey).collect(); + for (coldkey, claimed) in claimed_entries { + BasketClaimed::::mutate(new_hotkey, &coldkey, |c| { + *c = c.saturating_add(claimed); + }); + } - Ok(weight) + let escrow = Self::get_beta_escrow_account_id(); + for (netuid, alpha) in Self::get_basket_holdings(old_hotkey) { + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + old_hotkey, &escrow, netuid, alpha, + ); + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + new_hotkey, &escrow, netuid, alpha, + ); + } } - 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())) - } + /// Converts every validator's basket holding on a dissolving subnet into the fund's root + /// (TAO) slot: the escrow alpha is sold once and the proceeds are held as root stake under + /// the same escrow position. Fund shares, rates, and watermarks are untouched — the fund's + /// NAV is continuous across the conversion (minus slippage), so no per-staker accounting is + /// needed. Best-effort: a failed swap is logged and the slot is left for generic teardown. + pub fn convert_subnet_basket_holdings_to_root(netuid: NetUid) { + let escrow = Self::get_beta_escrow_account_id(); + let hotkeys: Vec = BasketShares::::iter_keys().collect(); - pub fn maybe_add_coldkey_index(coldkey: &T::AccountId) { - if !StakingColdkeys::::contains_key(coldkey) { - let n = NumStakingColdkeys::::get(); - StakingColdkeysByIndex::::insert(n, coldkey.clone()); - StakingColdkeys::::insert(coldkey.clone(), n); - NumStakingColdkeys::::mutate(|n| *n = n.saturating_add(1)); + for hotkey in hotkeys.iter() { + Self::convert_basket_holding_to_root(hotkey, &escrow, netuid); } } - 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)); + fn convert_basket_holding_to_root( + hotkey: &T::AccountId, + escrow: &T::AccountId, + netuid: NetUid, + ) { + let holding_alpha = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, escrow, netuid); + if holding_alpha.is_zero() { + return; + } - 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)); + let _ = with_transaction(|| { + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + escrow, + netuid, + holding_alpha, + ); - 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:?}"), + let tao = match Self::sell_basket_alpha_for_root_tao(netuid, holding_alpha) { + Ok(tao) => tao, + Err(err) => { + log::error!("Error converting basket holding to root: {err:?}"); + return TransactionOutcome::Rollback(Err(err)); } - } - } + }; - weight - } + // Hold the realized TAO as the fund's root-slot (cash) position. + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + escrow, + NetUid::ROOT, + tao.to_u64().into(), + ); + Self::credit_root_reserves(tao); - pub fn change_root_claim_type(coldkey: &T::AccountId, new_type: RootClaimTypeEnum) { - RootClaimType::::insert(coldkey.clone(), new_type.clone()); + Self::deposit_event(Event::BasketHoldingConverted { + hotkey: hotkey.clone(), + netuid, + tao, + }); - Self::deposit_event(Event::RootClaimTypeSet { - coldkey: coldkey.clone(), - root_claim_type: new_type, + TransactionOutcome::Commit(Ok::<(), DispatchError>(())) }); } - pub fn transfer_root_claimed_for_new_keys( + /// Sells basket `alpha` on `netuid` for TAO and lands it in the root subnet account, booking + /// the protocol outflow. The alpha must already have been removed from the escrow position. + /// Shared by claim redemption and dissolution conversion; callers stay transactional. + fn sell_basket_alpha_for_root_tao( netuid: NetUid, - old_hotkey: &T::AccountId, - new_hotkey: &T::AccountId, - old_coldkey: &T::AccountId, - new_coldkey: &T::AccountId, - ) { - let old_root_claimed = RootClaimed::::get((netuid, old_hotkey, old_coldkey)); - RootClaimed::::remove((netuid, old_hotkey, old_coldkey)); + alpha: AlphaBalance, + ) -> Result { + let out = Self::swap_alpha_for_tao( + netuid, + alpha, + T::SwapInterface::min_price::(), + true, + ) + .inspect_err(|err| log::error!("Error swapping basket alpha for TAO: {err:?}"))?; + + let root_subnet_account_id = Self::get_subnet_account_id(NetUid::ROOT) + .ok_or(Error::::RootNetworkDoesNotExist)?; + + Self::transfer_tao_from_subnet( + netuid, + &root_subnet_account_id, + out.amount_paid_out.into(), + ) + .inspect_err(|err| log::error!("Error transferring basket TAO from subnet: {err:?}"))?; + + Self::record_protocol_outflow(netuid, out.amount_paid_out); + + Ok(out.amount_paid_out) + } - RootClaimed::::mutate((netuid, new_hotkey, new_coldkey), |new_root_claimed| { - *new_root_claimed = old_root_claimed.saturating_add(*new_root_claimed); + // ========================================================================= + // 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)); } - pub fn transfer_root_claimable_for_new_hotkey( - old_hotkey: &T::AccountId, - new_hotkey: &T::AccountId, - ) { - let src_root_claimable = RootClaimable::::get(old_hotkey); - let mut dst_root_claimable = RootClaimable::::get(new_hotkey); - RootClaimable::::remove(old_hotkey); - - for (netuid, claimable_rate) in src_root_claimable.into_iter() { - dst_root_claimable - .entry(netuid) - .and_modify(|total| *total = total.saturating_add(claimable_rate)) - .or_insert(claimable_rate); + + /// Mark-to-market TAO value of `alpha` on `netuid` at the current pool (spot) 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 redemption sizing: a staker's owed shares are worth + /// `owed * N / P` TAO (fund NAV over outstanding shares), capped at the NAV so a claim can + /// never be marked above what the fund holds. u128 arithmetic: the u64*u64 product can + /// exceed U96F32's 96 integer bits at chain-scale magnitudes. + pub fn basket_payout_from(owed_shares: u64, nav: u64, shares_total: u64) -> u64 { + if owed_shares == 0 || shares_total == 0 || nav == 0 { + return 0; } + let payout = u128::from(owed_shares) + .saturating_mul(u128::from(nav)) + .checked_div(u128::from(shares_total)) + .unwrap_or(0) + .min(u128::from(u64::MAX)) as u64; + payout.min(nav) + } - RootClaimable::::insert(new_hotkey, dst_root_claimable); + /// A validator's fund NAV in TAO at spot prices (for views). + pub fn get_validator_basket_nav_tao(hotkey: &T::AccountId) -> TaoBalance { + let mut nav: u64 = 0; + for (netuid, alpha) in Self::get_basket_holdings(hotkey) { + nav = nav.saturating_add(Self::alpha_to_tao_value(netuid, alpha.to_u64())); + } + nav.into() } - /// 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::>(); + /// Current TAO payout a staker would realize (mark-to-market) by redeeming their owed + /// shares on a validator. + pub fn get_basket_payout_tao(hotkey: &T::AccountId, coldkey: &T::AccountId) -> u64 { + let owed_shares = Self::get_basket_owed_shares(hotkey, coldkey); + let shares_total = BasketShares::::get(hotkey); + let nav: u64 = Self::get_validator_basket_nav_tao(hotkey).to_u64(); + Self::basket_payout_from(owed_shares.min(shares_total), nav, shares_total) + } - for hotkey in hotkeys.iter() { - RootClaimable::::mutate(hotkey, |claimable| { - claimable.remove(&netuid); - }); + /// 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) { + total = total.saturating_add(Self::get_basket_payout_tao(&hotkey, coldkey)); } + total.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)> { + Self::get_basket_holdings(hotkey) + .into_iter() + .map(|(netuid, alpha)| { + let tao = Self::alpha_to_tao_value(netuid, alpha.to_u64()); + (netuid, alpha, tao.into()) + }) + .collect() + } + + /// 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 mut nav: u64 = 0; + for hotkey in BasketShares::::iter_keys() { + nav = nav.saturating_add(Self::get_validator_basket_nav_tao(&hotkey).to_u64()); + } + nav.into() + } - let _ = RootClaimed::::clear_prefix((netuid,), u32::MAX, None); + /// 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/pallets/subtensor/src/staking/helpers.rs b/pallets/subtensor/src/staking/helpers.rs index f11012f0e2..56fae49178 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 fund holdings `(hotkey, escrow, netuid)`, which + // are not nominations. Sweeping one would force-unstake the holding into the keyless + // escrow account (stranded, no controller) while leaving `BasketShares` untouched, + // silently shrinking the fund NAV that backs every staker's `owed * N/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/subnets/weights.rs b/pallets/subtensor/src/subnets/weights.rs index 39bcfb80b5..515b11185f 100644 --- a/pallets/subtensor/src/subnets/weights.rs +++ b/pallets/subtensor/src/subnets/weights.rs @@ -929,6 +929,90 @@ 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 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), + Error::::UidVecContainInvalidOne + ); + } + + // --- 9. Max-upscale the weights. + let max_upscaled_weights: Vec = vec_u16_max_upscale_to_u16(&values); + + // --- 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(); + 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); + + // --- 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_coldkey.rs b/pallets/subtensor/src/swap/swap_coldkey.rs index 2358fcecf1..1ba21a7e53 100644 --- a/pallets/subtensor/src/swap/swap_coldkey.rs +++ b/pallets/subtensor/src/swap/swap_coldkey.rs @@ -107,16 +107,14 @@ impl Pallet { let new_dest_alpha = Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, new_coldkey, netuid); - if !new_dest_alpha.is_zero() { - Self::transfer_root_claimed_for_new_keys( - netuid, - &hotkey, - &hotkey, - old_coldkey, - new_coldkey, - ); - - if netuid == NetUid::ROOT { + if netuid == NetUid::ROOT { + // Move the basket claimed watermark once, with the root-subnet iteration — + // unconditionally, NOT gated on current root stake: the signed watermark can be + // negative with zero stake (claim-then-unstake), which represents accrued owed + // shares that must follow the coldkey or be orphaned on the dead key. + Self::transfer_basket_claimed_for_new_coldkey(&hotkey, old_coldkey, new_coldkey); + + if !new_dest_alpha.is_zero() { // Register new coldkey with root stake Self::maybe_add_coldkey_index(new_coldkey); } diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs index 4c8a0af5a8..8bd6d20aaf 100644 --- a/pallets/subtensor/src/swap/swap_hotkey.rs +++ b/pallets/subtensor/src/swap/swap_hotkey.rs @@ -28,7 +28,7 @@ impl Pallet { /// * `NewHotKeyIsSameWithOld` - If the new hotkey is the same as the old hotkey. /// * `HotKeyAlreadyRegisteredInSubNet` - If the new hotkey is already registered in the subnet. /// * `NewHotKeyNotCleanForRootSwap` - If the swap touches root and the new hotkey - /// has outstanding `RootClaimable` entries or non-zero root stake. + /// has an outstanding basket fund (`BasketRate`/`BasketShares`) or non-zero root stake. /// * `NotEnoughBalanceToPaySwapHotKey` - If there is not enough balance to pay for the swap. pub fn do_swap_hotkey( origin: OriginFor, @@ -90,7 +90,8 @@ impl Pallet { }; if touches_root { ensure!( - RootClaimable::::get(new_hotkey).is_empty() + !BasketRate::::contains_key(new_hotkey) + && BasketShares::::get(new_hotkey) == 0 && Self::get_stake_for_hotkey_on_subnet(new_hotkey, NetUid::ROOT).is_zero(), Error::::NewHotKeyNotCleanForRootSwap ); @@ -571,8 +572,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( @@ -594,35 +604,13 @@ impl Pallet { } if netuid == NetUid::ROOT { - // 9. Transfer root claimable and root claimed only for the root subnet - // NOTE: we shouldn't transfer root claimable and root claimed for other subnets, - // otherwise root stakers won't be able to receive dividends. - Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey); - weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); - - // After transfer, new_hotkey has the full RootClaimable map. - // We use it to know which subnets have outstanding claims. - let subnets: Vec = RootClaimable::::get(new_hotkey) - .keys() - .copied() - .collect(); - weight.saturating_accrue(T::DbWeight::get().reads(1)); - - for subnet in subnets { - let claimed_coldkeys: Vec = - RootClaimed::::iter_prefix((subnet, old_hotkey)) - .map(|(coldkey, _)| coldkey) - .collect(); - weight - .saturating_accrue(T::DbWeight::get().reads(claimed_coldkeys.len() as u64)); - - for coldkey in claimed_coldkeys { - Self::transfer_root_claimed_for_new_keys( - subnet, old_hotkey, new_hotkey, &coldkey, &coldkey, - ); - weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); - } - } + // 9. Migrate the validator's whole basket fund only for the root subnet: shares, + // rate, per-coldkey claimed watermarks, and every escrow holding move by value. + // The clean-hotkey guard above makes this a move, not a merge. + let num_holdings = Self::get_basket_holdings(old_hotkey).len() as u64; + Self::transfer_basket_for_new_hotkey(old_hotkey, new_hotkey); + let ops = num_holdings.saturating_mul(2).saturating_add(4); + weight.saturating_accrue(T::DbWeight::get().reads_writes(ops, ops)); // Transfer AutoParentDelegationEnabled flag from old_hotkey to new_hotkey. // Only migrate if it was explicitly set, to preserve the storage default semantics. diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index dc7253d58c..5e577936a3 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -1,51 +1,211 @@ #![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, + BasketClaimed, BasketRate, BasketShares, DefaultMinRootClaimAmount, Error, Keys, + MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, NumStakingColdkeys, RootClaimableThreshold, + StakingColdkeys, StakingColdkeysByIndex, SubnetAlphaIn, SubnetMovingPrice, SubnetProtocolFlow, + SubnetTAO, SubnetworkN, Tempo, TotalStake, Uids, Weights, }; -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_core::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; +use subtensor_runtime_common::{AlphaBalance, NetUid, NetUidStorageIndex, TaoBalance, Token}; + +// ============================================================================= +// Helpers +// ============================================================================= + +/// 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)]) { + Uids::::insert(NetUid::ROOT, hotkey, uid); + let zipped: Vec<(u16, u16)> = dests.iter().map(|(n, w)| (u16::from(*n), *w)).collect(); + Weights::::insert(NetUidStorageIndex::ROOT, uid, zipped); +} + +/// 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)); +} + +/// Claims are fund-level and consult only the ROOT threshold entry; zero it for tests that +/// exercise small claims. +fn zero_claim_threshold() { + RootClaimableThreshold::::insert(NetUid::ROOT, I96F32::from_num(0)); +} + +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() +} + +fn fund_shares(hotkey: &U256) -> u64 { + BasketShares::::get(hotkey) +} + +fn has_fund(hotkey: &U256) -> bool { + BasketRate::::get(hotkey) > I96F32::from_num(0) +} + +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_set_claim_type() { +fn test_populate_staking_maps() { new_test_ext(1).execute_with(|| { - let coldkey = U256::from(1); + let owner_coldkey = U256::from(1000); + let coldkey1 = U256::from(1001); + let coldkey2 = U256::from(1002); + let coldkey3 = U256::from(1003); + let hotkey = U256::from(1004); + let _netuid = add_dynamic_network(&hotkey, &owner_coldkey); + let netuid2 = NetUid::from(2); + + let root_stake = 200_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey1, + NetUid::ROOT, + root_stake.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey2, + NetUid::ROOT, + root_stake.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey3, + netuid2, + root_stake.into(), + ); + + assert_eq!(NumStakingColdkeys::::get(), 0); + + // Populate maps through block step + run_to_block(2); + + assert_eq!(NumStakingColdkeys::::get(), 2); + + assert!(StakingColdkeysByIndex::::contains_key(0)); + assert!(StakingColdkeysByIndex::::contains_key(1)); + + assert!(StakingColdkeys::::contains_key(coldkey1)); + assert!(StakingColdkeys::::contains_key(coldkey2)); + assert!(!StakingColdkeys::::contains_key(coldkey3)); + }); +} + +#[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); + + assert_eq!( + RootClaimableThreshold::::get(NetUid::ROOT), + DefaultMinRootClaimAmount::::get() + ); + + let threshold = 1000u64; + assert_ok!(SubtensorModule::sudo_set_root_claim_threshold( + RawOrigin::Root.into(), + NetUid::ROOT, + threshold + )); + assert_eq!( + RootClaimableThreshold::::get(NetUid::ROOT), + I96F32::from(threshold) + ); + + // Errors: bad origin, non-ROOT netuid (only the ROOT entry is consulted by claims, so + // anything else would be silently inert and is rejected), out-of-range value. + assert_err!( + SubtensorModule::sudo_set_root_claim_threshold( + RawOrigin::Signed(hotkey).into(), + NetUid::ROOT, + threshold + ), + DispatchError::BadOrigin, + ); + + assert_err!( + SubtensorModule::sudo_set_root_claim_threshold(RawOrigin::Root.into(), netuid, 500), + Error::::InvalidRootClaimThreshold, + ); + assert_eq!( + RootClaimableThreshold::::get(netuid), + DefaultMinRootClaimAmount::::get(), + "non-ROOT entry must not be written" + ); + + assert_err!( + SubtensorModule::sudo_set_root_claim_threshold( + RawOrigin::Root.into(), + NetUid::ROOT, + MAX_ROOT_CLAIM_THRESHOLD + 1 + ), + Error::::InvalidRootClaimThreshold, + ); + }); +} - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); +// ============================================================================= +// Beta basket: setting weights (extrinsic validation) +// ============================================================================= - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); +#[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); + + // `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_drain_emissions() { +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( @@ -54,931 +214,1313 @@ fn test_claim_root_with_drain_emissions() { NetUid::ROOT, root_stake.into(), ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + + // Route the basket 100% back into this subnet. + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert_eq!(fund_shares(&hotkey), 0); + + let pending_root_alpha = 1_000_000u64; + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + pending_root_alpha.into(), + AlphaBalance::ZERO, + ); + + // Fund shares minted, escrow holds the basket alpha, and a claimable rate exists. + assert!(fund_shares(&hotkey) > 0); + assert!(escrow_alpha(&hotkey, netuid) > 0); + assert!(has_fund(&hotkey)); + + // At a ~1:1 pool price the fund NAV and outstanding shares should match (N/P starts + // at 1): the escrow alpha marked at ~1 equals the TAO-denominated shares. + assert_abs_diff_eq!( + SubtensorModule::get_validator_basket_nav_tao(&hotkey).to_u64(), + fund_shares(&hotkey), + epsilon = 10u64, + ); + }); +} + +#[test] +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); - let initial_total_hotkey_alpha = 10_000_000u64; + 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, - initial_total_hotkey_alpha.into(), + 10_000_000u64.into(), + ); + + // No root weights set for the validator. + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + // Without weights the root dividend is recycled: no basket, no claimable. + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert_eq!(fund_shares(&hotkey), 0); + assert!(!has_fund(&hotkey)); + }); +} + +#[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 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); + + 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_a, + netuid_a, + 10_000_000u64.into(), + ); + + // Route the basket entirely into subnet B (different from the dividend origin A). + set_root_weights_direct(&hotkey, 0, &[(netuid_b, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid_a, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + + // The holding should be on B, not A; the fund is denominated at the validator level. + assert!(escrow_alpha(&hotkey, netuid_b) > 0); + assert_eq!(escrow_alpha(&hotkey, netuid_a), 0); + assert!(fund_shares(&hotkey) > 0); + assert!(has_fund(&hotkey)); + }); +} + +// ============================================================================= +// 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); + zero_claim_threshold(); + + 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 old_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + 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. The fund-level claim sells the staker's pro-rata slice of BOTH + // holdings 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))); + + 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 (pro-rata fund redemption, swapped to root TAO) +// ============================================================================= + +#[test] +fn test_root_basket_claim_swaps_to_root() { + 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); + zero_claim_threshold(); + + 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, &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, ); - // Check new validator stake - let validator_take_percent = 0.18f64; + let shares_before = fund_shares(&hotkey); + assert!(shares_before > 0); + let root_before = root_stake_of(&hotkey, &coldkey); + assert_eq!(root_before, root_stake); + + // Claim: the staker's owed fraction of the fund is sold to TAO and staked on root. + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + + // Staker's root stake increased, fund shares consumed, watermark advanced. + assert!(root_stake_of(&hotkey, &coldkey) > root_before); + assert!(fund_shares(&hotkey) < shares_before); + assert!(BasketClaimed::::get(hotkey, coldkey) > 0); + }); +} - let new_validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( +#[test] +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); + + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_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(), + ); + 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, + 10_000_000u64.into(), ); - 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, + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 10_000_000u64.into(), + AlphaBalance::ZERO, ); - // Check claimable + let alice_before = root_stake_of(&hotkey, &alice); + let bob_before = root_stake_of(&hotkey, &bob); - 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_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(alice))); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(bob))); - assert_abs_diff_eq!( - claimable.saturating_to_num::(), - calculated_rate, - epsilon = 0.001f64, + 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_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); + + 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(), ); - // Claim root alpha + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Keep - ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Keep); + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); + let basket_before = escrow_alpha(&hotkey, netuid); + let shares_before = fund_shares(&hotkey); + assert!(basket_before > 0); + assert!(shares_before > 0); - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); + // Swap the validator's root hotkey: the whole fund must follow it. + let mut weight = Weight::zero(); + assert_ok!(SubtensorModule::perform_hotkey_swap_on_one_subnet( + &hotkey, + &new_hotkey, + &mut weight, + NetUid::ROOT, + false, + )); + // Fund moved to the new hotkey, old fund emptied. + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert_eq!(fund_shares(&hotkey), 0); + assert!(!has_fund(&hotkey)); assert_abs_diff_eq!( - new_stake, - (I96F32::from(root_stake) * claimable).saturating_to_num::(), - epsilon = 10u64, + escrow_alpha(&new_hotkey, netuid), + basket_before, + epsilon = 10u64 ); + assert_eq!(fund_shares(&new_hotkey), shares_before); + assert!(has_fund(&new_hotkey)); + }); +} - // Check root claimed value saved +// ============================================================================= +// Beta basket: subnet dissolution converts the holding into the fund's root slot +// ============================================================================= - let claimed = RootClaimed::::get((netuid, &hotkey, &coldkey)); - assert_eq!(u128::from(new_stake), claimed); +#[test] +fn test_root_basket_dissolve_converts_to_root_slot() { + 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); - // Distribute pending root alpha (round 2) + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); + + 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, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - // Check claimable (round 2) + let subnet_holding = escrow_alpha(&hotkey, netuid); + assert!(subnet_holding > 0); + assert_eq!(escrow_alpha(&hotkey, NetUid::ROOT), 0); + let shares_before = fund_shares(&hotkey); - 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); + // Dissolving the subnet converts the holding into the fund's root (TAO) slot: shares, + // rates, and watermarks are untouched — NAV is continuous minus slippage. + assert_ok!(SubtensorModule::do_dissolve_network(netuid)); - assert_abs_diff_eq!( - claimable2.saturating_to_num::(), - calculated_rate + claimable.saturating_to_num::(), - epsilon = 0.001f64, + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert!( + escrow_alpha(&hotkey, NetUid::ROOT) > 0, + "holding must be converted to the fund's root slot" ); + assert_eq!(fund_shares(&hotkey), shares_before); + assert!(has_fund(&hotkey)); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); + // The staker's claim survives dissolution and is redeemable from the root slot. + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + assert!(root_stake_of(&hotkey, &coldkey) > root_before); + }); +} + +/// Dissolution must not create a windfall for a "fresh" staker who joined after the basket +/// accrued (zero owed): after conversion, only the staker who accrued the fund can redeem it. +#[test] +fn test_root_basket_dissolve_preserves_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); - 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::(); + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - assert_abs_diff_eq!( - u64::from(new_stake2), - calculated_new_stake2, - epsilon = 10u64, + // 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)]); - // Check root claimed value saved (round 2) + 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 fund. + assert_eq!(root_stake_of(&hotkey, &alice), root_stake_of(&hotkey, &bob)); + assert!(SubtensorModule::get_basket_owed_shares(&hotkey, &alice) > 0); + assert_eq!( + SubtensorModule::get_basket_owed_shares(&hotkey, &bob), + 0 + ); + + assert_ok!(SubtensorModule::do_dissolve_network(netuid)); + + // Owed entitlements are untouched by the conversion. + assert!(SubtensorModule::get_basket_owed_shares(&hotkey, &alice) > 0); + assert_eq!( + SubtensorModule::get_basket_owed_shares(&hotkey, &bob), + 0 + ); + + let alice_before = root_stake_of(&hotkey, &alice); + let bob_before = root_stake_of(&hotkey, &bob); - let claimed = RootClaimed::::get((netuid, &hotkey, &coldkey)); - assert_eq!(u128::from(u64::from(new_stake2)), claimed); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(alice))); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(bob))); + + 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 fund 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") +// ============================================================================= + +/// 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_adding_stake_proportionally_for_two_stakers() { +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 alice_coldkey = U256::from(1003); - let bob_coldkey = U256::from(1004); + 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); + zero_claim_threshold(); - let root_stake = 1_000_000u64; mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &alice_coldkey, + &coldkey, NetUid::ROOT, - root_stake.into(), + 2_000_000u64.into(), ); mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &bob_coldkey, - NetUid::ROOT, - root_stake.into(), + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + // --- 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, + 1_000_000u64.into(), + AlphaBalance::ZERO, ); + let ts_after_distribute = TotalStake::::get().to_u64(); + assert_eq!( + ts_before_distribute, ts_after_distribute, + "distribution must be TotalStake-neutral" + ); + + // --- 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))); + let ts_after_claim = TotalStake::::get().to_u64(); + assert_eq!( + ts_before_claim, ts_after_claim, + "redemption must be TotalStake-neutral" + ); + }); +} + +/// The basket compounds: if the escrow position grows (validator earns more on the subnet) +/// after accrual, a sole staker redeems MORE than the fund's original NAV — the `N/P` +/// multiplier carries the growth through to the staker. +#[test] +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); + zero_claim_threshold(); - let root_stake_rate = 0.1f64; + // Single root staker => owns 100% of the basket. mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &other_coldkey, + &coldkey, NetUid::ROOT, - (8 * 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 - ),); - - // Distribute pending root alpha - - 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]) - )); - - // Check stakes - let validator_take_percent = 0.18f64; + let shares = fund_shares(&hotkey); + let escrow_before = escrow_alpha(&hotkey, netuid); + assert!(shares > 0); - let alice_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // Validator earns more nominator dividends on the subnet => escrow value grows, + // shares stay fixed (N/P rises above 1). + SubtensorModule::increase_stake_for_hotkey_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; + 100_000_000u64.into(), + ); + let escrow_after = escrow_alpha(&hotkey, netuid); + assert!( + escrow_after > escrow_before, + "escrow must grow with dividends" + ); - assert_eq!(alice_stake, bob_stake); + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); - assert_abs_diff_eq!(alice_stake, estimated_stake as u64, epsilon = 100u64,); + // The sole staker realizes the *grown* basket, strictly more than the original shares' + // par value. + assert!( + gain > shares, + "compounding: realized {gain} must exceed original share value {shares}" + ); }); } +/// Claiming drains the basket exactly: after all stakers redeem, the escrow position and the +/// outstanding fund shares both go to ~zero (Σ payouts == fund value; no residual, +/// no over-draw). #[test] -fn test_claim_root_adding_stake_disproportionally_for_two_stakers() { +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 alice_coldkey = U256::from(1003); - let bob_coldkey = U256::from(1004); + 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 - - let alice_root_stake = 1_000_000u64; - let bob_root_stake = 2_000_000u64; - let other_root_stake = 7_000_000u64; + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - 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, + &alice, NetUid::ROOT, - bob_root_stake.into(), + 1_000_000u64.into(), ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &other_coldkey, + &bob, NetUid::ROOT, - (other_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)]); - // 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(), + 10_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]) - )); - - // 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; + let escrow_filled = escrow_alpha(&hotkey, netuid); + assert!(escrow_filled > 0); - assert_eq!(2 * alice_stake, bob_stake); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(alice))); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(bob))); - assert_abs_diff_eq!(alice_stake, alice_estimated_stake as u64, epsilon = 100u64,); + // Escrow and shares fully drained (allow tiny rounding dust). + assert!( + escrow_alpha(&hotkey, netuid) <= 10, + "escrow must be drained, got {}", + escrow_alpha(&hotkey, netuid) + ); + assert!( + fund_shares(&hotkey) <= 10, + "shares must be drained, got {}", + fund_shares(&hotkey) + ); }); } +/// Disproportionate root stake yields proportionate payout: a staker with 2x the root stake +/// redeems ~2x the TAO. #[test] -fn test_claim_root_with_changed_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 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); + zero_claim_threshold(); - 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 - ),); - - // 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, ); - 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 alice_before = root_stake_of(&hotkey, &alice); + let bob_before = root_stake_of(&hotkey, &bob); - let bob_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(alice))); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(bob))); - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; + 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_eq!(alice_stake, bob_stake); + 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); + }); +} - assert_abs_diff_eq!(alice_stake, estimated_stake as u64, epsilon = 100u64,); +/// 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); - // Remove stake - let stake_decrement = root_stake / 2u64; + SubtensorModule::set_tao_weight(u64::MAX); - assert_ok!(SubtensorModule::remove_stake( - RuntimeOrigin::signed(bob_coldkey,), - hotkey, + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, NetUid::ROOT, - stake_decrement.into(), - )); + 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, ); - 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 basket_b = escrow_alpha(&hotkey, netuid_b); + let basket_c = escrow_alpha(&hotkey, netuid_c); - // Check new stakes + 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); + }); +} - let alice_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); +/// 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 bob_stake2: 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, - &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, + &coldkey, 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, + 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, )); - // Check new stakes + let stored = Weights::::get(NetUidStorageIndex::ROOT, 0u16); + assert_eq!(stored, vec![(u16::from(netuid), u16::MAX)]); + }); +} - let alice_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); +/// 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); - let bob_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + 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, - &bob_coldkey, - netuid, - ) - .into(); - - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; + &coldkey, + NetUid::ROOT, + 2_000_000u64.into(), + ); - let alice_stake_diff2 = alice_stake3 - alice_stake2; - let bob_stake_diff2 = bob_stake3 - bob_stake2; + // 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, + )); - 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,); + 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. +// ============================================================================= + +/// 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_claim_root_with_drain_emissions_and_swap_claim_type() { +fn test_claim1_principal_never_lost() { 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); + zero_claim_threshold(); - let root_stake = 2_000_000u64; + let principal = 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(), + principal.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(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - // Claim root alpha - - let validator_take_percent = 0.18f64; + // Dividend distribution did not touch the staker's root principal. + assert_eq!(root_stake_of(&hotkey, &coldkey), principal); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Swap - ),); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Swap); + // Claiming only adds TAO to the root principal (never subtracts). + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + assert!(root_stake_of(&hotkey, &coldkey) >= principal); + }); +} - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); +/// 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); - // Check new stake + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - let new_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &coldkey, + &alice, 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, + 2_000_000u64.into(), ); - - // Distribute and claim pending root alpha (round 2) + 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, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stake (2) + let alice_before = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + assert!(alice_before > 0); - let new_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // 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, - &coldkey, + &bob, NetUid::ROOT, - ) - .into(); + 5_000_000u64.into(), + ); + SubtensorModule::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( + &hotkey, + &bob, + 5_000_000u64, + ); - // 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; + // Alice's accrued basket is unchanged; Bob has accrued nothing of it. + assert_eq!( + SubtensorModule::get_basket_payout_tao(&hotkey, &alice), + alice_before + ); + assert_eq!(SubtensorModule::get_basket_payout_tao(&hotkey, &bob), 0); + }); +} - assert_abs_diff_eq!( - new_stake2, - new_stake + estimated_stake_increment2 as u64, - epsilon = 10000u64, +/// 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); + zero_claim_threshold(); + + 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(), ); - // Distribute and claim pending root alpha (round 3) + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); 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(coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stake (3) + let before = SubtensorModule::get_basket_payout_tao(&hotkey, &coldkey); + assert!(before > 0); - let new_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // 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, - &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, + &escrow, + netuid, + before.into(), ); + + // The sole staker's claimable value compounded upward. + assert!(SubtensorModule::get_basket_payout_tao(&hotkey, &coldkey) > before); }); } -/// cargo test --package pallet-subtensor --lib -- tests::claim_root::test_claim_root_with_run_coinbase --exact --nocapture +/// 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 fund leaves the `N/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_claim_root_swap_failure_does_not_consume_claim() { +fn test_claim4_no_dilution_or_skim_on_late_stake() { 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); - SubnetTAO::::insert(netuid, TaoBalance::from(50_000_000_000_u64)); - SubnetAlphaIn::::insert(netuid, AlphaBalance::from(100_000_000_000_u64)); + zero_claim_threshold(); + // Equal root stake for Alice and Bob. + let stake = 2_000_000u64; 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, + &alice, NetUid::ROOT, - 18_000_000_u64.into(), + stake.into(), ); mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &owner_coldkey, netuid, - 10_000_000_u64.into(), + 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, - 10_000_000_u64.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); - assert_ok!(SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::Swap - )); + // The basket compounds heavily (escrow value grows ~4x; shares 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 subnet_account = SubtensorModule::get_subnet_account_id(netuid).unwrap(); - Balances::make_free_balance_be(&subnet_account, 0.into()); + let mult = |hk: &U256| -> f64 { + let n = SubtensorModule::get_validator_basket_nav_tao(hk).to_u64() as f64; + let p = fund_shares(hk) as f64; + n / 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_tao(&hotkey, &alice); - let root_claimed_before = RootClaimed::::get((netuid, &hotkey, &coldkey)); - let root_stake_before = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + // 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, - &coldkey, + &bob, NetUid::ROOT, + stake.into(), ); - 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); + SubtensorModule::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(&hotkey, &bob, stake); - assert_noop!( - SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), BTreeSet::from([netuid])), - Error::::InsufficientBalance + // (4a) Bob cannot claim any of the existing basket; Alice's accrual is untouched. + assert_eq!(SubtensorModule::get_basket_payout_tao(&hotkey, &bob), 0); + assert_eq!( + SubtensorModule::get_basket_payout_tao(&hotkey, &alice), + alice_before ); - assert_eq!( - RootClaimed::::get((netuid, &hotkey, &coldkey)), - root_claimed_before + // A new distribution deposits into the already-compounded basket. + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, ); - 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 + + // (4b) Deposit-at-NAV: the N/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_tao(&hotkey, &alice); + let bob_after = SubtensorModule::get_basket_payout_tao(&hotkey, &bob); + + // 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 holding. #[test] -fn test_claim_root_with_run_coinbase() { +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) - Tempo::::insert(netuid, 1); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - let root_stake = 200_000_000u64; - SubnetTAO::::insert(NetUid::ROOT, TaoBalance::from(root_stake)); + // 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 + ); + // Single 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)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, ); - // 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); - - 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(); + 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!(new_stake > 0); - }); -} + 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 fund. + assert_abs_diff_eq!(owed, nav, epsilon = 10u64); -#[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); + // Breakdown lists exactly the one funded subnet, and its TAO value sums to the NAV. + assert_eq!(basket.len(), 1); + let (slot_netuid, slot_alpha, slot_tao) = basket.first().copied().unwrap(); + assert_eq!(slot_netuid, netuid); + assert!(slot_alpha.to_u64() > 0); // alpha held + assert_eq!(slot_tao.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_claim_root_with_block_emissions() { +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); @@ -987,588 +1529,514 @@ fn test_claim_root_with_block_emissions() { remove_owner_registration_stake(netuid); Tempo::::insert(netuid, 1); - SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); 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 + // Turn root-sell ON: moving price + spot price > 1. 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"); + SubnetTAO::::insert(netuid, TaoBalance::from(10_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); + zero_claim_threshold(); + assert!( + SubtensorModule::get_network_root_sell_flag(&[netuid]), + "root sell flag must be ON" + ); - 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(), ); - 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); + // Validator routes its basket back into the subnet. + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - 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); - }); -} + assert_eq!(escrow_alpha(&hotkey, netuid), 0); -#[test] -fn test_populate_staking_maps() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1000); - let coldkey1 = U256::from(1001); - let coldkey2 = U256::from(1002); - let coldkey3 = U256::from(1003); - let hotkey = U256::from(1004); - let _netuid = add_dynamic_network(&hotkey, &owner_coldkey); - let netuid2 = NetUid::from(2); + // Run real blocks: emission accrues and drains through the coinbase. + run_to_block(3); - let root_stake = 200_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey1, - NetUid::ROOT, - root_stake.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey2, - NetUid::ROOT, - root_stake.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &coldkey3, - netuid2, - root_stake.into(), + // The basket formed end-to-end from actual block emission. + assert!( + escrow_alpha(&hotkey, netuid) > 0, + "basket must form from coinbase emission" ); + assert!(fund_shares(&hotkey) > 0); + assert!(has_fund(&hotkey)); - assert_eq!(NumStakingColdkeys::::get(), 0); - - // Populate maps through block step - - run_to_block(2); - - assert_eq!(NumStakingColdkeys::::get(), 2); - - assert!(StakingColdkeysByIndex::::contains_key(0)); - assert!(StakingColdkeysByIndex::::contains_key(1)); - - assert!(StakingColdkeys::::contains_key(coldkey1)); - assert!(StakingColdkeys::::contains_key(coldkey2)); - assert!(!StakingColdkeys::::contains_key(coldkey3)); + // And it is redeemable to root TAO. + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + 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, minting fund +/// shares, 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_claim_root_coinbase_distribution() { +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); - 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)); + SubtensorModule::set_tao_weight(u64::MAX); 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(), - ); - - // 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"); - - // Set TAOFlow > 0 - SubnetTaoFlow::::insert(netuid, 2222_i64); - - // Check total issuance (saved to pending alpha divs) - run_to_block(2); - - 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 - ); - - 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 + 10_000_000u64.into(), ); - // Epoch pending alphas divs is distributed - - run_to_block(3); - - assert_eq!(u64::from(PendingRootAlphaDivs::::get(netuid)), 0u64); + // Validator opts out of subnets: 100% of the basket weight on root (UID 0). + set_root_weights_direct(&hotkey, 0, &[(NetUid::ROOT, u16::MAX)]); - let claimable = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); + assert_eq!(escrow_alpha(&hotkey, NetUid::ROOT), 0); + assert_eq!(fund_shares(&hotkey), 0); - let validator_take_percent = 0.18f64; - let calculated_rate = (expected_pending_root_alpha_divs * 2f64) - * (1f64 - validator_take_percent) - / (root_stake as f64); - - assert_abs_diff_eq!( - claimable.saturating_to_num::(), - calculated_rate, - epsilon = 0.001f64, + 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, ); - }); -} - -#[test] -fn test_sudo_set_num_root_claims() { - new_test_ext(1).execute_with(|| { - let coldkey = U256::from(1003); + let ts_after = TotalStake::::get().to_u64(); - assert_noop!( - SubtensorModule::sudo_set_num_root_claims(RuntimeOrigin::signed(coldkey), 50u64), - DispatchError::BadOrigin - ); + // A root slot now exists: shares minted, escrow holds root stake, claimable rate set. + let escrow_root = escrow_alpha(&hotkey, NetUid::ROOT); + let shares = fund_shares(&hotkey); + assert!(escrow_root > 0, "escrow must hold root stake"); + assert!(shares > 0, "fund shares must be minted"); + assert!(has_fund(&hotkey)); - assert_noop!( - SubtensorModule::sudo_set_num_root_claims( - RuntimeOrigin::root(), - MAX_NUM_ROOT_CLAIMS + 1, - ), - Error::::InvalidNumRootClaim - ); + // Held at 1:1 (N/P starts at 1): escrow root stake ~= minted shares. + assert_abs_diff_eq!(escrow_root, shares, epsilon = 10u64); - let new_value = 27u64; - assert_ok!(SubtensorModule::sudo_set_num_root_claims( - RuntimeOrigin::root(), - new_value, - ),); + // No subnet alpha was bought for the root slice (no subnet escrow position created). + assert_eq!(escrow_alpha(&hotkey, netuid), 0); - assert_eq!(NumRootClaim::::get(), new_value); + // 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, shares are consumed, and it is TotalStake-neutral (no swap, +/// no minted TAO — total root stake is conserved, just moved between coldkeys). #[test] -fn test_claim_root_with_swap_coldkey() { +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); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - 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::ROOT, 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 - - 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 shares_before = fund_shares(&hotkey); + let escrow_before = escrow_alpha(&hotkey, NetUid::ROOT); + let root_before = root_stake_of(&hotkey, &coldkey); + assert!(shares_before > 0); + assert!(escrow_before > 0); - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); + let ts_before = TotalStake::::get().to_u64(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + let ts_after = TotalStake::::get().to_u64(); - // Check root claimed value saved - let new_coldkey = U256::from(10030); + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); + let escrow_after = escrow_alpha(&hotkey, NetUid::ROOT); - assert_eq!( - u128::from(new_stake), - RootClaimed::::get((netuid, &hotkey, &coldkey)) - ); - assert_eq!( - 0u128, - RootClaimed::::get((netuid, &hotkey, &new_coldkey)) + // 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 ); - // Swap coldkey - assert_ok!(SubtensorModule::do_swap_coldkey(&coldkey, &new_coldkey,)); - - // Check swapped keys claimed values - - assert_eq!(0u128, RootClaimed::::get((netuid, &hotkey, &coldkey))); - assert_eq!( - u128::from(new_stake), - RootClaimed::::get((netuid, &hotkey, &new_coldkey,)) - ); + // Shares consumed, watermark advanced, TotalStake untouched (no swap, no mint). + assert!(fund_shares(&hotkey) < shares_before); + assert!(BasketClaimed::::get(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 the original share +/// value. #[test] -fn test_claim_root_with_swap_hotkey() { +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); // Set TAO weight to 1.0 + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - 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()); + set_root_weights_direct(&hotkey, 0, &[(NetUid::ROOT, u16::MAX)]); - // 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, ); - // 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 shares = fund_shares(&hotkey); + assert!(shares > 0); - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); + // Simulate root dividends compounding the escrow's root stake (N 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); - // Check root claimed value saved - let new_hotkey = U256::from(10030); + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); - assert_eq!( - u128::from(new_stake), - RootClaimed::::get((netuid, &hotkey, &coldkey,)) - ); - assert_eq!( - 0u128, - RootClaimed::::get((netuid, &new_hotkey, &coldkey,)) + assert!( + gain > shares, + "compounding: realized {gain} must exceed original share value {shares}" ); + }); +} - let _old_claimable = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); +// ============================================================================= +// Edge cases: adversarial invariants +// ============================================================================= - assert!(!RootClaimable::::get(new_hotkey).contains_key(&netuid)); +/// Conservation under interleaved activity: three stakers with unequal stakes, a fund spread +/// across a subnet holding AND the root (cash) slot, three deposits interleaved with claims. +/// After everyone claims, every holding and the share supply must drain to ~zero (no stranded +/// value, no over-draw), and TotalStake must be conserved through the whole sequence. +#[test] +fn test_root_basket_conservation_interleaved() { + new_test_ext(1).execute_with(|| { + let owner_a = U256::from(1001); + let hotkey = U256::from(1002); + let alice = U256::from(1003); + let bob = U256::from(1004); + let carol = U256::from(1005); + let owner_b = U256::from(2001); + let hotkey_b = U256::from(2002); + + 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); - // Swap hotkey - let mut weight = Weight::zero(); - assert_ok!(SubtensorModule::perform_hotkey_swap_on_one_subnet( + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); + + // Unequal root stakes 1:2:3. + for (ck, stake) in [ + (alice, 1_000_000u64), + (bob, 2_000_000u64), + (carol, 3_000_000u64), + ] { + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &ck, + NetUid::ROOT, + stake.into(), + ); + } + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &new_hotkey, - &mut weight, - netuid, - false, - )); - - // 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,)) - ); - assert_eq!( - 0u128, - RootClaimed::::get((netuid, &new_hotkey, &coldkey,)) + &owner_a, + netuid_a, + 10_000_000u64.into(), ); - assert!(RootClaimable::::get(hotkey).contains_key(&netuid)); + // Fund composition: 50% subnet B, 50% root (cash) slot. + set_root_weights_direct(&hotkey, 0, &[(netuid_b, 32768), (NetUid::ROOT, 32768)]); - assert!(!RootClaimable::::get(new_hotkey).contains_key(&netuid)); + let ts_start = TotalStake::::get().to_u64(); + let deposit = |amount: u64| { + SubtensorModule::distribute_emission( + netuid_a, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + amount.into(), + AlphaBalance::ZERO, + ); + }; + + // Interleave deposits and claims. + deposit(1_000_000); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(alice))); + deposit(2_000_000); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(bob))); + deposit(1_500_000); + + // Final round: everyone claims everything. + for ck in [alice, bob, carol] { + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(ck))); + } + + // The fund is fully drained: no stranded value in any holding, no outstanding shares. + let residual_b = escrow_alpha(&hotkey, netuid_b); + let residual_root = escrow_alpha(&hotkey, NetUid::ROOT); + let residual_shares = fund_shares(&hotkey); + assert!(residual_b <= 100, "subnet holding stranded: {residual_b}"); + assert!(residual_root <= 100, "root slot stranded: {residual_root}"); + assert!(residual_shares <= 100, "shares stranded: {residual_shares}"); + + // TotalStake conserved across the whole interleaved sequence. + assert_eq!( + ts_start, + TotalStake::::get().to_u64(), + "TAO minted or destroyed by deposit/claim round trips" + ); }); } +/// Claim idempotency: an immediate second claim must be a complete no-op — the payout staked +/// onto root by the first claim must not re-inflate the staker's owed (the watermark rebase +/// covers it). #[test] -fn test_claim_root_on_network_deregistration() { +fn test_root_basket_claim_idempotent() { 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); + zero_claim_threshold(); - 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)]); - // Distribute pending root alpha - - 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(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::claim_root(RuntimeOrigin::signed(coldkey))); - assert_ok!(SubtensorModule::do_dissolve_network(netuid)); + let root_after_first = root_stake_of(&hotkey, &coldkey); + let shares_after_first = fund_shares(&hotkey); + let escrow_after_first = escrow_alpha(&hotkey, netuid); + // The payout staked on root must not re-inflate owed; fixed-point truncation in the + // watermark rebase may leave at most ~1 share of dust, never a compounding remainder. + assert!( + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey) <= 1, + "payout staked on root re-inflated owed: {}", + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey) + ); - assert!(!RootClaimed::::contains_key(( - netuid, &hotkey, &coldkey, - ))); - assert!(!RootClaimable::::get(hotkey).contains_key(&netuid)); + // Repeated claims: at most the 1-share dust moves once; nothing compounds. + for _ in 0..3 { + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + } + assert!(root_stake_of(&hotkey, &coldkey) <= root_after_first + 2); + assert!(shares_after_first.saturating_sub(fund_shares(&hotkey)) <= 2); + assert!(escrow_after_first.saturating_sub(escrow_alpha(&hotkey, netuid)) <= 2); }); } +/// Self-referential origin: the fund already holds alpha on the subnet the dividend originates +/// from, so the deposit's own origin sell moves the fund's mark mid-flight. The NAV snapshot is +/// taken after the sell, so: (a) the existing staker is not diluted, and (b) a late equal +/// staker's entire entitlement equals only the new deposit's increment — the mid-flight price +/// move cannot be used to skim the existing holder's value. #[test] -fn test_claim_root_threshold() { +fn test_root_basket_self_referential_origin() { 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); + zero_claim_threshold(); - let threshold = 1000u64; - assert_ok!(SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Root.into(), + 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, - threshold - )); - assert_eq!( - RootClaimableThreshold::::get(netuid), - I96F32::from(threshold) + 10_000_000u64.into(), ); - let threshold = 2000u64; - assert_ok!(SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Signed(owner_coldkey).into(), + // 100% of the basket routed back into the origin subnet: every future dividend both + // sells and buys the very asset the fund holds. + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + // Alice accrues the first deposit alone. + SubtensorModule::distribute_emission( netuid, - threshold - )); - assert_eq!( - RootClaimableThreshold::::get(netuid), - I96F32::from(threshold) + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, ); + let alice_before = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + assert!(alice_before > 0); - // Errors - assert_err!( - SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Signed(hotkey).into(), - netuid, - threshold - ), - DispatchError::BadOrigin, + // Bob joins with equal stake (watermark rebased as real add_stake would). + 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); - assert_err!( - SubtensorModule::sudo_set_root_claim_threshold( - RawOrigin::Signed(owner_coldkey).into(), - netuid, - MAX_ROOT_CLAIM_THRESHOLD + 1 - ), - Error::::InvalidRootClaimThreshold, + // Second deposit with the fund holding origin-subnet alpha. + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_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_after = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + let bob_after = SubtensorModule::get_basket_payout_tao(&hotkey, &bob); - assert_err!( - SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), BTreeSet::new()), - Error::::InvalidSubnetNumber + // (a) Alice is not diluted by the mid-flight sell of the fund's own holding (small AMM + // slippage tolerance). + assert!( + alice_after + 1_000 >= alice_before, + "existing holder diluted: {alice_before} -> {alice_after}" ); - assert_err!( - SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from_iter((0u16..=10u16).into_iter().map(NetUid::from)) - ), - Error::::InvalidSubnetNumber + // (b) Bob's whole entitlement equals only Alice's increment from the new deposit: the + // self-referential price move gave him no claim on her pre-existing value. + let alice_increment = alice_after.saturating_sub(alice_before); + assert!(bob_after > 0); + assert_abs_diff_eq!(bob_after, alice_increment, epsilon = 2_000u64); + assert!( + bob_after < alice_before, + "late staker skimmed via self-referential deposit: bob={bob_after} alice_before={alice_before}" ); }); } +/// Fixed-point saturation regression: at chain-scale magnitudes (fund shares and NAV around +/// 2e16 rao — the full TAO supply), the mint and payout math must be exact. The previous +/// `U96F32` formulation saturated at ~7.9e28 in the intermediate product, silently underpaying +/// by orders of magnitude. #[test] -fn test_claim_root_with_unrelated_subnets() { +fn test_root_basket_large_magnitudes_no_saturation() { + // Unit check: owed * nav overflows 96 fixed-point integer bits (4e32 > 2^96) but must + // compute exactly in u128. A saturating implementation returns ~3.9e12 here. + let supply = 21_000_000u64 * 1_000_000_000; // 2.1e16 rao + assert_eq!( + SubtensorModule::basket_payout_from(supply, supply, supply), + supply + ); + // Half the shares of a supply-sized fund pay exactly half the NAV. + assert_eq!( + SubtensorModule::basket_payout_from(supply / 2, supply, supply), + supply / 2 + ); + + // End-to-end: a large dividend deposited into a supply-scale fund mints ~value * P / N + // shares, not a saturated fraction of it. new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); @@ -1576,594 +2044,589 @@ fn test_claim_root_with_unrelated_subnets() { 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 + // Very deep pool at price 1 so a 2e13 trade has negligible slippage. + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_000_000u64)); + + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - 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(), + 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( + // Fund at supply scale: escrow holds 2e16 alpha (price 1 => NAV 2e16), 2e16 shares out. + // (Direct stake write: the mock helper's subnet-balance top-up overflows the test-chain + // issuance at this scale.) + let fund_scale = 20_000_000_000_000_000u64; // 2e16 + let escrow_ck = SubtensorModule::get_beta_escrow_account_id(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &owner_coldkey, + &escrow_ck, netuid, + fund_scale.into(), ); - assert_eq!(old_validator_stake, initial_total_hotkey_alpha.into()); + BasketShares::::insert(hotkey, fund_scale); - // Distribute pending root alpha + // Deposit a 2e13-rao dividend directly into the basket (bypassing the emission split, + // which is stake-proportional and not what is under test): N/P == 1, so ~2e13 shares + // must be minted. + let dividend = 20_000_000_000_000u64; // 2e13 + SubtensorModule::distribute_root_alpha_to_basket(&hotkey, netuid, dividend.into()); - let pending_root_alpha = 1_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, + let minted = fund_shares(&hotkey).saturating_sub(fund_scale); + assert!( + minted > dividend / 2 && minted < dividend * 2, + "mint saturated or mispriced: minted {minted} for a {dividend} deposit at N/P=1" ); - - // Claim root alpha - - 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( - &hotkey, - &coldkey, - unrelated_subnet_uid, - ) - .into(); - - assert_eq!(new_stake, 0u64,); - - // Check root claim for correct subnet - - // before - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - - assert_eq!(new_stake, 0u64,); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); - - // after - let new_stake: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) - .into(); - - assert!(new_stake > 0u64); - - // Check root claimed value saved - - let claimed = RootClaimed::::get((netuid, &hotkey, &coldkey)); - assert_eq!(u128::from(new_stake), claimed); + assert_abs_diff_eq!(minted, dividend, epsilon = dividend / 100); }); } +/// Removing root stake never destroys already-accrued entitlement: the watermark rebase makes +/// `owed = rate*(stake-Δ) - (claimed - rate*Δ)` algebraically identical to the pre-unstake owed. #[test] -fn test_claim_root_fill_root_alpha_dividends_per_subnet() { +fn test_root_basket_unstake_preserves_accrued() { 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); + zero_claim_threshold(); - let root_stake = 2_000_000u64; + let 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(), + 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(), ); + 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; 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 owed_before = SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey); + let payout_before = SubtensorModule::get_basket_payout_tao(&hotkey, &coldkey); + assert!(owed_before > 0); - let validator_take_percent = 0.18f64; - let estimated_root_claim_dividends = - (pending_root_alpha as f64) * (1f64 - validator_take_percent); + // Unstake half the root stake, mirroring the real remove_stake path (stake decrease + + // watermark rebase). + let removed = stake / 2; + SubtensorModule::decrease_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + removed.into(), + ); + SubtensorModule::remove_stake_adjust_root_claimed_for_hotkey_and_coldkey( + &hotkey, + &coldkey, + removed.into(), + ); + // Accrued entitlement is unchanged (±1 for fixed-point floor). + let owed_after = SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey); + assert_abs_diff_eq!(owed_after, owed_before, epsilon = 1u64); assert_abs_diff_eq!( - estimated_root_claim_dividends as u64, - u64::from(root_claim_dividends1), - epsilon = 100u64, + SubtensorModule::get_basket_payout_tao(&hotkey, &coldkey), + payout_before, + epsilon = 1u64 + ); + + // And it remains fully claimable. + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); + assert_abs_diff_eq!(gain, payout_before, epsilon = 100u64); + }); +} + +/// Pro-rata redemption preserves fund composition: after one of two equal stakers claims from a +/// fund with a 2:1 split across two subnets, the ratio between the remaining holdings is +/// unchanged, and the second claimant's payout matches the first (no ordering advantage beyond +/// AMM slippage). +#[test] +fn test_root_basket_claim_preserves_composition() { + new_test_ext(1).execute_with(|| { + let owner_a = U256::from(1001); + let hotkey = U256::from(1002); + let alice = U256::from(1003); + let bob = U256::from(1004); + 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); + zero_claim_threshold(); + + let stake = 2_000_000u64; + for ck in [alice, bob] { + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &ck, + NetUid::ROOT, + stake.into(), + ); + } + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_a, + netuid_a, + 10_000_000u64.into(), ); + // 2:1 composition across B and C. + set_root_weights_direct(&hotkey, 0, &[(netuid_b, 43690), (netuid_c, 21845)]); + SubtensorModule::distribute_emission( - netuid, + netuid_a, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 6_000_000u64.into(), AlphaBalance::ZERO, ); - let root_claim_dividends2 = RootAlphaDividendsPerSubnet::::get(netuid, hotkey); + let b_before = escrow_alpha(&hotkey, netuid_b) as f64; + let c_before = escrow_alpha(&hotkey, netuid_c) as f64; + assert!(b_before > 0.0 && c_before > 0.0); + let ratio_before = b_before / c_before; - // Check RootAlphaDividendsPerSubnet is cleaned each epoch - assert_eq!(root_claim_dividends1, root_claim_dividends2); + // Alice (half the shares) claims. + let alice_root_before = root_stake_of(&hotkey, &alice); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(alice))); + let alice_gain = root_stake_of(&hotkey, &alice).saturating_sub(alice_root_before); + + // Composition is preserved: both holdings shrank by the same fraction. + let b_after = escrow_alpha(&hotkey, netuid_b) as f64; + let c_after = escrow_alpha(&hotkey, netuid_c) as f64; + let ratio_after = b_after / c_after; + assert!( + (ratio_after - ratio_before).abs() / ratio_before < 0.001, + "claim skewed composition: {ratio_before} -> {ratio_after}" + ); + + // Bob's payout matches Alice's (equal stakes), modulo slippage from her claim. + let bob_root_before = root_stake_of(&hotkey, &bob); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(bob))); + let bob_gain = root_stake_of(&hotkey, &bob).saturating_sub(bob_root_before); + assert!(alice_gain > 0 && bob_gain > 0); + assert_abs_diff_eq!(alice_gain, bob_gain, epsilon = 3_000u64); }); } +/// A dividend whose rate increment rounds to zero (huge claimant base, tiny deposit) must be +/// rolled back and recycled — never deposited without crediting stakers, which would strand +/// value and break `Σ owed == P`. #[test] -fn test_claim_root_with_keep_subnets() { +fn test_root_basket_dust_deposit_recycled() { 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); + zero_claim_threshold(); - let root_stake = 2_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + // Enormous claimant base: increment = shares / total_root rounds below I96F32's 2^-32 + // resolution for a ~1e6 deposit. (Direct stake write: the mock helper's subnet-balance + // top-up overflows the test-chain issuance at this scale.) + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &coldkey, NetUid::ROOT, - root_stake.into(), + 10_000_000_000_000_000u64.into(), // 1e16 ); - - 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( + let ts_before = TotalStake::::get().to_u64(); + // Deposit directly into the basket: the rate increment (~1e3 / 1e16 < 2^-32) rounds to + // zero, so the whole deposit must roll back. + SubtensorModule::distribute_root_alpha_to_basket(&hotkey, netuid, 1_000u64.into()); + + // The deposit was rolled back and recycled: no shares, no rate, no escrow position, and + // no TAO moved. + assert_eq!(fund_shares(&hotkey), 0); + assert!(!has_fund(&hotkey)); + assert_eq!(escrow_alpha(&hotkey, netuid), 0); + assert_eq!(TotalStake::::get().to_u64(), ts_before); + }); +} + +/// A claim below the dust threshold is a complete no-op: nothing is consumed, and the full +/// amount remains claimable once the threshold permits. +#[test] +fn test_root_basket_threshold_skip_consumes_nothing() { + 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(), ); - assert_eq!(old_validator_stake, initial_total_hotkey_alpha.into()); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); - // Distribute pending root alpha - - let pending_root_alpha = 1_000_000u64; + // Accrue less than the threshold. SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 100_000u64.into(), AlphaBalance::ZERO, ); + RootClaimableThreshold::::insert(NetUid::ROOT, I96F32::from_num(1_000_000u64)); - let claimable = *RootClaimable::::get(hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); - - // Claim root alpha - assert_err!( - SubtensorModule::set_root_claim_type( - RuntimeOrigin::signed(coldkey), - RootClaimTypeEnum::KeepSubnets { - subnets: BTreeSet::new() - }, - ), - Error::::InvalidSubnetNumber - ); - - 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); - - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(coldkey), - BTreeSet::from([netuid]) - )); + let owed_before = SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey); + let shares_before = fund_shares(&hotkey); + let escrow_before = escrow_alpha(&hotkey, netuid); + let root_before = root_stake_of(&hotkey, &coldkey); + assert!(owed_before > 0); - 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, - ); + // Below threshold: skipped, nothing consumed. + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + assert_eq!( + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey), + owed_before + ); + assert_eq!(fund_shares(&hotkey), shares_before); + assert_eq!(escrow_alpha(&hotkey, netuid), escrow_before); + assert_eq!(root_stake_of(&hotkey, &coldkey), root_before); + + // Lower the threshold: the full amount pays out. + zero_claim_threshold(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + assert!(root_stake_of(&hotkey, &coldkey) > root_before); + assert!(fund_shares(&hotkey) <= 10); }); } +/// Coldkey swap must carry a staker's basket entitlement even when their current root stake is +/// zero: the signed watermark deliberately represents "accrued owed with no stake" (negative +/// watermark after unstake-all), and gating the transfer on live stake would orphan it on the +/// dead coldkey. #[test] -fn test_claim_root_keep_subnets_swap_claim_type() { +fn test_root_basket_coldkey_swap_carries_owed_with_zero_stake() { 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 old_coldkey = U256::from(1003); + let new_coldkey = 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); + zero_claim_threshold(); - let root_stake = 2_000_000u64; + let 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, + &old_coldkey, NetUid::ROOT, - (9 * root_stake).into(), + 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(), ); + 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(), + 1_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); - - 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( + // Unstake ALL root stake, mirroring the real remove_stake path. The watermark goes + // negative; owed is preserved with zero live stake. + SubtensorModule::decrease_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &coldkey, + &old_coldkey, NetUid::ROOT, - ) - .into(); + stake.into(), + ); + SubtensorModule::remove_stake_adjust_root_claimed_for_hotkey_and_coldkey( + &hotkey, + &old_coldkey, + stake.into(), + ); + + assert_eq!(root_stake_of(&hotkey, &old_coldkey), 0); + let owed_before = SubtensorModule::get_basket_owed_shares(&hotkey, &old_coldkey); + assert!(owed_before > 0, "owed must survive unstake-all"); + assert!( + BasketClaimed::::get(hotkey, old_coldkey) < 0, + "watermark must be negative after unstake-all" + ); - let estimated_stake_increment = (pending_root_alpha as f64) - * (1f64 - validator_take_percent) - * current_price - * root_stake_rate; + // Swap the coldkey. + assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); + // The entitlement followed the coldkey — nothing orphaned on the dead key. assert_abs_diff_eq!( - new_stake, - root_stake + estimated_stake_increment as u64, - epsilon = 10000u64, + SubtensorModule::get_basket_owed_shares(&hotkey, &new_coldkey), + owed_before, + epsilon = 1u64 + ); + assert_eq!( + BasketClaimed::::get(hotkey, old_coldkey), + 0, + "old coldkey must hold no watermark after swap" + ); + + // And it is claimable by the new coldkey. + let root_before = root_stake_of(&hotkey, &new_coldkey); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed( + new_coldkey + ))); + assert!( + root_stake_of(&hotkey, &new_coldkey) > root_before, + "new coldkey must be able to realize the carried entitlement" ); }); } +/// A claim whose marked estimate is positive but whose per-holding alpha takes all floor to +/// zero (high-price, tiny-alpha holding) must be a complete no-op: settling would burn the +/// staker's owed shares for a zero payout. #[test] -fn test_claim_root_default_mode_keep() { +fn test_root_basket_zero_realized_claim_burns_nothing() { 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); + zero_claim_threshold(); + + // High-price pool: spot ~= 100 TAO per alpha. + SubnetTAO::::insert(netuid, TaoBalance::from(100_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); + + let stake = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + stake.into(), + ); + + // Synthetic fund state: 1e6 shares outstanding against a tiny 5_000-alpha holding + // (marked NAV = 5_000 * 100 = 500_000), and the staker owed 100 shares. + // estimated_payout = 100 * 500_000 / 1_000_000 = 50 > 0, but the alpha take is + // 5_000 * 100 / 1_000_000 = 0.5 -> floors to 0. + let escrow = SubtensorModule::get_beta_escrow_account_id(); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &escrow, + netuid, + 5_000u64.into(), + ); + BasketShares::::insert(hotkey, 1_000_000u64); + BasketRate::::insert(hotkey, I96F32::from_num(0.00005)); // owed = 100 + + let owed_before = SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey); + // ~100 (I96F32 floors the 0.00005 rate slightly); anything in this range keeps the + // estimate positive while every alpha take floors to zero. + assert!((90..=100).contains(&owed_before), "owed = {owed_before}"); + let shares_before = fund_shares(&hotkey); + let escrow_before = escrow_alpha(&hotkey, netuid); + let root_before = root_stake_of(&hotkey, &coldkey); - assert_eq!(RootClaimType::::get(coldkey), RootClaimTypeEnum::Swap); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + + // Complete no-op: no shares burned, no watermark advanced, nothing moved. + assert_eq!( + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey), + owed_before, + "owed shares must not be burned for a zero payout" + ); + assert_eq!(fund_shares(&hotkey), shares_before); + assert_eq!(escrow_alpha(&hotkey, netuid), escrow_before); + assert_eq!(root_stake_of(&hotkey, &coldkey), root_before); + assert_eq!(BasketClaimed::::get(hotkey, coldkey), 0); }); } +/// A fully-drained fund accepts new deposits cleanly: the revived fund's value belongs to the +/// (current) stakers and is fully redeemable; the drained epoch cannot leak into the new one. #[test] -fn test_claim_root_with_moved_stake() { +fn test_root_basket_revives_after_full_drain() { 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 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 - SubtokenEnabled::::insert(NetUid::ROOT, true); - NetworksAdded::::insert(NetUid::ROOT, true); + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - 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, + &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)]); - // 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; + // Epoch 1: accrue and fully drain. 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(coldkey))); + assert!(fund_shares(&hotkey) <= 10, "epoch-1 fund should be drained"); - 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,); - - // Distribute pending root alpha - - let pending_root_alpha = 10_000_000u64; + // Epoch 2: a new deposit into the drained fund. SubtensorModule::distribute_emission( netuid, AlphaBalance::ZERO, AlphaBalance::ZERO, - pending_root_alpha.into(), + 1_000_000u64.into(), AlphaBalance::ZERO, ); + let epoch2_value = SubtensorModule::get_validator_basket_nav_tao(&hotkey).to_u64(); + assert!(epoch2_value > 0); - // Transfer stake to other coldkey - let stake_decrement = root_stake / 2u64; - - assert_ok!(SubtensorModule::transfer_stake( - RuntimeOrigin::signed(bob_coldkey,), - eve_coldkey, - hotkey, - NetUid::ROOT, - NetUid::ROOT, - stake_decrement.into(), - )); - - let eve_stake: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &eve_coldkey, - netuid, - ) - .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::claim_root( - RuntimeOrigin::signed(eve_coldkey), - BTreeSet::from([netuid]) - )); - - // Check new stakes + // The sole staker redeems ~the entire epoch-2 value; nothing was lost to the drained + // epoch's residual dust. + let root_before = root_stake_of(&hotkey, &coldkey); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + let gain = root_stake_of(&hotkey, &coldkey).saturating_sub(root_before); + assert_abs_diff_eq!(gain, epoch2_value, epsilon = epoch2_value / 100); + assert!(fund_shares(&hotkey) <= 20); + }); +} - let alice_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &alice_coldkey, - netuid, - ) - .into(); +/// 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 value 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); - let bob_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); + SubtensorModule::set_tao_weight(u64::MAX); + zero_claim_threshold(); - let eve_stake2: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + 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 - - let pending_root_alpha = 10_000_000u64; - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - pending_root_alpha.into(), - AlphaBalance::ZERO, + 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]) - )); - - // Check new stakes - - let alice_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, - &alice_coldkey, + &owner_coldkey, netuid, - ) - .into(); + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(NetUid::ROOT, u16::MAX)]); - let bob_stake3: u64 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &bob_coldkey, - netuid, - ) - .into(); + // 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 value in the escrow. + for _ in 0..2 { + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + } - let estimated_stake = (pending_root_alpha as f64) * (1f64 - validator_take_percent) / 2f64; + let escrow_before = escrow_alpha(&hotkey, NetUid::ROOT); + assert!(escrow_before > 0); - let alice_stake_diff2 = alice_stake3 - alice_stake2; - let bob_stake_diff2 = bob_stake3 - bob_stake2; + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); - 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,); + // The sole real staker drains the whole root slot: no value 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}" + ); }); } diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 73cdacac7e..3e54bcc9f0 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -4818,3 +4818,342 @@ 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_v2; + + new_test_ext(1).execute_with(|| { + const MIGRATION_NAME: &[u8] = b"migrate_seed_beta_basket_v2"; + + 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!(BasketShares::::get(hotkey), 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_v2::(); + 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 alpha, + // now held by the escrow as the fund's holding on this subnet. + let expected_alpha = 1_000_000u64; + assert_abs_diff_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid) + .to_u64(), + expected_alpha, + epsilon = 10u64, + ); + + // The legacy per-subnet state was converted into the unified fund and drained. + let shares = BasketShares::::get(hotkey); + let fund_rate = BasketRate::::get(hotkey); + assert!(shares > 0, "fund shares must be seeded"); + assert!(fund_rate > I96F32::from_num(0), "fund rate must be seeded"); + assert!( + RootClaimable::::get(hotkey).is_empty(), + "legacy claimable must be drained" + ); + + // Conservation: the sole staker's owed shares equal the outstanding fund shares + // (Σ owed == P), so the whole seeded fund is claimable and nothing is stranded. + assert_abs_diff_eq!( + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey), + shares, + epsilon = 10u64, + ); + + // Idempotent: a second run is a no-op (only reads the flag). + let w2 = migrate_seed_beta_basket_v2::(); + assert_eq!(w2, ::DbWeight::get().reads(1)); + }); +} + +/// Migration edge case: a legacy slot whose claimed watermark exceeds its gross accrual (the +/// historical overclaim bug) must seed nothing negative — no shares, no escrow position — and +/// the staker's owed must floor at zero rather than underflow. +#[test] +fn test_migrate_seed_beta_basket_overclaimed_slot() { + use crate::migrations::migrate_seed_beta_basket::migrate_seed_beta_basket_v2; + + 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 root_stake = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + root_stake.into(), + ); + + // gross = 0.5 * 2_000_000 = 1_000_000, but claimed = 5_000_000 (overclaimed). + RootClaimable::::mutate(hotkey, |m| { + m.insert(netuid, I96F32::from_num(0.5)); + }); + RootClaimed::::insert((netuid, hotkey, coldkey), 5_000_000u128); + + let w = migrate_seed_beta_basket_v2::(); + assert!(!w.is_zero()); + + // remaining is negative -> floored: no escrow position, no shares. + 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 + ); + assert_eq!(BasketShares::::get(hotkey), 0); + + // The converted watermark exceeds the converted rate * stake, so owed floors at zero. + assert_eq!( + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey), + 0 + ); + + // Legacy state drained. + assert!(RootClaimable::::get(hotkey).is_empty()); + assert_eq!(RootClaimed::::get((netuid, hotkey, coldkey)), 0u128); + }); +} + +/// Migration edge case: multi-subnet legacy state with different prices and a partially-claimed +/// staker. Each staker's owed TAO value must be preserved exactly through the conversion +/// (`owed_new = Σ pₛ (rateₛ·stake − claimedₛ)`), and `Σ owed == BasketShares` must hold so the +/// seeded fund is exactly claimable — nothing stranded, nothing over-promised. +#[test] +fn test_migrate_seed_beta_basket_multi_subnet_preserves_owed() { + use crate::migrations::migrate_seed_beta_basket::migrate_seed_beta_basket_v2; + + new_test_ext(1).execute_with(|| { + let owner_a = U256::from(1001); + let hotkey = U256::from(1002); + let alice = U256::from(1003); + let bob = U256::from(1004); + let owner_b = U256::from(2001); + let hotkey_b = U256::from(2002); + + let netuid_a = add_dynamic_network(&hotkey, &owner_a); + let netuid_b = add_dynamic_network(&hotkey_b, &owner_b); + + // Different fixed conversion prices: p_a = 2.0, p_b = 0.5. + SubnetMovingPrice::::insert(netuid_a, I96F32::from_num(2.0)); + SubnetMovingPrice::::insert(netuid_b, I96F32::from_num(0.5)); + + // Alice 1_000_000, Bob 3_000_000 root stake => total 4_000_000. + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &alice, + NetUid::ROOT, + 1_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &bob, + NetUid::ROOT, + 3_000_000u64.into(), + ); + + // Legacy rates: 0.5 alpha/stake on A, 0.25 on B. Alice has already claimed part of A. + RootClaimable::::mutate(hotkey, |m| { + m.insert(netuid_a, I96F32::from_num(0.5)); + m.insert(netuid_b, I96F32::from_num(0.25)); + }); + let alice_claimed_a = 100_000u128; + RootClaimed::::insert((netuid_a, hotkey, alice), alice_claimed_a); + + // Expected owed in TAO-valued shares at the fixed prices: + // Alice: p_a*(0.5*1e6 - 1e5) + p_b*(0.25*1e6) = 2*400_000 + 0.5*250_000 = 925_000 + // Bob: p_a*(0.5*3e6) + p_b*(0.25*3e6) = 2*1_500_000 + 0.5*750_000 = 3_375_000 + let expected_alice = 925_000u64; + let expected_bob = 3_375_000u64; + + let w = migrate_seed_beta_basket_v2::(); + assert!(!w.is_zero()); + + let owed_alice = SubtensorModule::get_basket_owed_shares(&hotkey, &alice); + let owed_bob = SubtensorModule::get_basket_owed_shares(&hotkey, &bob); + assert_abs_diff_eq!(owed_alice, expected_alice, epsilon = 5u64); + assert_abs_diff_eq!(owed_bob, expected_bob, epsilon = 5u64); + + // Conservation: the outstanding fund shares equal the sum of all owed (Σ owed == P). + let shares = BasketShares::::get(hotkey); + assert_abs_diff_eq!(shares, owed_alice + owed_bob, epsilon = 10u64); + + // Holdings: the still-outstanding legacy alpha per subnet, unpriced. + // A: 0.5*4e6 - 1e5 = 1_900_000 alpha; B: 0.25*4e6 = 1_000_000 alpha. + let escrow = SubtensorModule::get_beta_escrow_account_id(); + assert_abs_diff_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid_a) + .to_u64(), + 1_900_000u64, + epsilon = 5u64 + ); + assert_abs_diff_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid_b) + .to_u64(), + 1_000_000u64, + epsilon = 5u64 + ); + + // Legacy maps fully drained for this hotkey. + assert!(RootClaimable::::get(hotkey).is_empty()); + assert_eq!(RootClaimed::::get((netuid_a, hotkey, alice)), 0u128); + }); +} + +/// Migration regression: a chain that already ran the superseded v1 seed migration (which +/// consumed the key `"migrate_seed_beta_basket"` and seeded the abandoned per-slot +/// `BasketPrincipal` model) must still be converted by v2 — this is exactly the name-collision +/// scenario that would have silently stranded every basket had v2 reused the v1 key. +/// +/// v1-state specifics v2 must tolerate: +/// * the escrow already holds the slot alpha (possibly MORE than `remaining`, from +/// compounding) — no double-staking, and the surplus carries the old `E/P` into `N/P`; +/// * orphaned `BasketPrincipal` entries — cleared; +/// * a legacy root-slot (netuid 0) rate with escrow root stake — converted at price 1, capped +/// at the escrow's actual root stake, and the escrow's root stake is excluded from the +/// claimant base. +#[test] +fn test_migrate_seed_beta_basket_v2_after_v1_already_ran() { + use crate::migrations::migrate_seed_beta_basket::{deprecated, migrate_seed_beta_basket_v2}; + + 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 escrow = SubtensorModule::get_beta_escrow_account_id(); + + SubnetMovingPrice::::insert(netuid, I96F32::from_num(1.0)); + + // The v1 migration already ran and consumed its key. + HasMigrationRun::::insert(b"migrate_seed_beta_basket".to_vec(), true); + + // Staker root stake. + let root_stake = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + root_stake.into(), + ); + + // Legacy v1 state: subnet slot with rate 0.5 => remaining = 1_000_000 alpha, but the + // escrow ALREADY holds 1_200_000 (v1 staked 1_000_000 and it compounded by 200_000), + // plus the orphaned per-slot principal record. + let remaining = 1_000_000u64; + let compounded_escrow = 1_200_000u64; + RootClaimable::::mutate(hotkey, |m| { + m.insert(netuid, I96F32::from_num(0.5)); + }); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &escrow, + netuid, + compounded_escrow.into(), + ); + deprecated::BasketPrincipal::::insert(hotkey, netuid, AlphaBalance::from(remaining)); + + // Legacy v1 root-slot state: rate 0.1 => gross 200_000, but the escrow only actually + // holds 150_000 root stake — the conversion must cap at the real backing. + let root_slot_backing = 150_000u64; + RootClaimable::::mutate(hotkey, |m| { + m.insert(NetUid::ROOT, I96F32::from_num(0.1)); + }); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &escrow, + NetUid::ROOT, + root_slot_backing.into(), + ); + + let w = migrate_seed_beta_basket_v2::(); + assert!(!w.is_zero()); + assert!(HasMigrationRun::::get( + b"migrate_seed_beta_basket_v2".to_vec() + )); + + // v2 ran despite v1's consumed key: the fund is seeded. + let shares = BasketShares::::get(hotkey); + let rate = BasketRate::::get(hotkey); + assert!(rate > I96F32::from_num(0), "fund rate must be seeded"); + + // Shares = remaining*p (subnet, p=1) + min(gross, backing) (root slot, p=1). + assert_abs_diff_eq!(shares, remaining + root_slot_backing, epsilon = 5u64); + + // No double-staking: the escrow's compounded holding is untouched, and the surplus + // carries the old slot's E/P multiplier into the fund's N/P (> 1). + assert_abs_diff_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid) + .to_u64(), + compounded_escrow, + epsilon = 1u64 + ); + + // Orphaned per-slot principal cleared. + assert!(!deprecated::BasketPrincipal::::contains_key( + hotkey, netuid + )); + + // Legacy claim state drained. + assert!(RootClaimable::::get(hotkey).is_empty()); + + // The staker's owed is fully backed and claimable: owed == shares (sole staker, no + // prior claims), and claiming realizes ~the whole fund. + assert_abs_diff_eq!( + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey), + shares, + epsilon = 5u64 + ); + RootClaimableThreshold::::insert(NetUid::ROOT, I96F32::from_num(0)); + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000u64)); + let root_before = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + ) + .to_u64(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); + let gain = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &coldkey, + NetUid::ROOT, + ) + .to_u64() + .saturating_sub(root_before); + // Compounded subnet holding (1_200_000 at price ~1) + root slot backing (150_000). + assert!( + gain > remaining + root_slot_backing, + "claim must realize the compounded fund, got {gain}" + ); + }); +} diff --git a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs index 426572bdcd..3c7963e7b6 100644 --- a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs +++ b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs @@ -2467,6 +2467,16 @@ 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)); + 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( netuid, @@ -2476,24 +2486,15 @@ 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]) - )); - - let stake_after_claim: u64 = - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hk1, &coldkey, netuid) - .into(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey))); - let hk1_root_claimed = RootClaimed::::get((netuid, &hk1, &coldkey)); - let hk1_claimable = *RootClaimable::::get(hk1).get(&netuid).unwrap(); + let hk1_claimed = BasketClaimed::::get(hk1, coldkey); + let hk1_rate = BasketRate::::get(hk1); - assert_eq!(u128::from(stake_after_claim), hk1_root_claimed); - assert!(!RootClaimable::::get(hk2).contains_key(&netuid)); + // Claiming swaps the fund to TAO on root, so we only assert the watermark advanced; the + // rest of the test verifies that a NON-root single-subnet swap does not move fund state. + assert!(hk1_claimed > 0); + assert!(BasketRate::::get(hk2) == I96F32::from_num(0)); System::set_block_number(System::block_number() + HotkeySwapOnSubnetInterval::get()); assert_ok!(SubtensorModule::do_swap_hotkey( @@ -2505,17 +2506,17 @@ fn test_revert_claim_root_with_swap_hotkey() { )); assert_eq!( - RootClaimed::::get((netuid, &hk2, &coldkey)), - 0u128, - "hk2 RootClaimed must be zero after swap" + BasketClaimed::::get(hk2, coldkey), + 0i128, + "hk2 BasketClaimed must be zero after non-root swap" ); assert_eq!( - RootClaimed::::get((netuid, &hk1, &coldkey)), - hk1_root_claimed, - "hk2 must have hk1's RootClaimed after swap" + BasketClaimed::::get(hk1, coldkey), + hk1_claimed, + "hk1 must retain its BasketClaimed after non-root swap" ); - assert!(RootClaimable::::get(hk1).contains_key(&netuid)); - assert!(!RootClaimable::::get(hk2).contains_key(&netuid)); + assert_eq!(BasketRate::::get(hk1), hk1_rate); + assert!(BasketRate::::get(hk2) == I96F32::from_num(0)); // Revert: hk2 -> hk1 step_block(20); @@ -2528,21 +2529,21 @@ fn test_revert_claim_root_with_swap_hotkey() { )); assert_eq!( - RootClaimed::::get((netuid, &hk2, &coldkey)), - 0u128, - "hk2 RootClaimed must be zero after revert" + BasketClaimed::::get(hk2, coldkey), + 0i128, + "hk2 BasketClaimed must be zero after revert" ); assert_eq!( - RootClaimed::::get((netuid, &hk1, &coldkey)), - hk1_root_claimed, - "hk1 RootClaimed must be restored after revert" + BasketClaimed::::get(hk1, coldkey), + hk1_claimed, + "hk1 BasketClaimed must be restored after revert" ); - assert!(!RootClaimable::::get(hk2).contains_key(&netuid)); + assert!(BasketRate::::get(hk2) == I96F32::from_num(0)); assert_eq!( - *RootClaimable::::get(hk1).get(&netuid).unwrap(), - hk1_claimable, - "hk1 RootClaimable must be restored after revert" + BasketRate::::get(hk1), + hk1_rate, + "hk1 BasketRate must be restored after revert" ); }); } @@ -2953,6 +2954,16 @@ 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)); + 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; SubtensorModule::distribute_emission( @@ -2963,21 +2974,13 @@ fn test_swap_hotkey_root_claims_unchanged_if_not_root() { AlphaBalance::ZERO, ); - assert_ok!(SubtensorModule::claim_root( - RuntimeOrigin::signed(staker_coldkey), - BTreeSet::from([netuid]) - )); - - let claimable = RootClaimable::::get(neuron_hotkey) - .get(&netuid) - .copied(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed( + staker_coldkey + ))); - assert!(claimable.is_some()); - let claimable = claimable.unwrap_or_default(); - - assert!(claimable > 0); - - assert!(RootClaimed::::get((netuid, &neuron_hotkey, &staker_coldkey,)) > 0u128); + let rate = BasketRate::::get(neuron_hotkey); + assert!(rate > I96F32::from_num(0)); + assert!(BasketClaimed::::get(neuron_hotkey, staker_coldkey) > 0i128); step_block(20); assert_ok!(SubtensorModule::do_swap_hotkey( @@ -2988,14 +2991,14 @@ fn test_swap_hotkey_root_claims_unchanged_if_not_root() { false )); - // Claimable and claimed should stay on old hotkey + // Fund rate and claimed watermark should stay on old hotkey (non-root swap). + assert_eq!(BasketRate::::get(neuron_hotkey), rate); + assert!(BasketClaimed::::get(neuron_hotkey, staker_coldkey) > 0i128); assert_eq!( - RootClaimable::::get(neuron_hotkey) - .get(&netuid) - .copied(), - Some(claimable) + BasketRate::::get(new_hotkey), + I96F32::from_num(0), + "non-root swap must not move the fund" ); - assert!(RootClaimed::::get((netuid, &neuron_hotkey, &staker_coldkey,)) > 0u128); }); } @@ -3032,6 +3035,16 @@ 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)); + 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; SubtensorModule::distribute_emission( @@ -3042,25 +3055,15 @@ 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]) - )); - - let claimable = RootClaimable::::get(neuron_hotkey) - .get(&netuid_1) - .copied(); - assert!(claimable.is_some()); - let claimable = claimable.unwrap_or_default(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed( + staker_coldkey + ))); - assert!(claimable > 0); + let rate = BasketRate::::get(neuron_hotkey); + assert!(rate > I96F32::from_num(0)); - let claimed = RootClaimed::::get((netuid_1, &neuron_hotkey, &staker_coldkey)); - assert!(claimed > 0u128); + let claimed = BasketClaimed::::get(neuron_hotkey, staker_coldkey); + assert!(claimed > 0i128); step_block(20); assert_ok!(SubtensorModule::do_swap_hotkey( @@ -3071,17 +3074,13 @@ fn test_swap_hotkey_root_claims_changed_if_root() { false )); - // Claimable and claimed should be transferred to new hotkey + // The whole fund (rate + claimed watermark) is transferred to the new hotkey. + assert_eq!(BasketRate::::get(neuron_hotkey_new), rate); assert_eq!( - RootClaimable::::get(neuron_hotkey_new) - .get(&netuid_1) - .copied(), - Some(claimable) - ); - assert_eq!( - RootClaimed::::get((netuid_1, &neuron_hotkey_new, &staker_coldkey,)), + BasketClaimed::::get(neuron_hotkey_new, staker_coldkey), claimed ); + assert_eq!(BasketRate::::get(neuron_hotkey), I96F32::from_num(0)); }); } @@ -3121,6 +3120,16 @@ 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)); + 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; SubtensorModule::distribute_emission( @@ -3131,25 +3140,15 @@ 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]) - )); - - let claimable = RootClaimable::::get(neuron_hotkey) - .get(&netuid_1) - .copied(); - assert!(claimable.is_some()); - let claimable = claimable.unwrap_or_default(); + assert_ok!(SubtensorModule::claim_root(RuntimeOrigin::signed( + staker_coldkey + ))); - assert!(claimable > 0); + let rate = BasketRate::::get(neuron_hotkey); + assert!(rate > I96F32::from_num(0)); - let claimed = RootClaimed::::get((netuid_1, &neuron_hotkey, &staker_coldkey)); - assert!(claimed > 0u128); + let claimed = BasketClaimed::::get(neuron_hotkey, staker_coldkey); + assert!(claimed > 0i128); step_block(20); assert_ok!(SubtensorModule::do_swap_hotkey( @@ -3160,17 +3159,13 @@ fn test_swap_hotkey_root_claims_changed_if_all_subnets() { false )); - // Claimable and claimed should be transferred to new hotkey - assert_eq!( - RootClaimable::::get(neuron_hotkey_new) - .get(&netuid_1) - .copied(), - Some(claimable) - ); + // The whole fund (rate + claimed watermark) is transferred to the new hotkey. + assert_eq!(BasketRate::::get(neuron_hotkey_new), rate); assert_eq!( - RootClaimed::::get((netuid_1, &neuron_hotkey_new, &staker_coldkey,)), + BasketClaimed::::get(neuron_hotkey_new, staker_coldkey), claimed ); + assert_eq!(BasketRate::::get(neuron_hotkey), I96F32::from_num(0)); }); } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 15607d1e09..186fefa74e 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -678,7 +678,6 @@ subtensor_macros::define_proxy_filters! { SubtensorModule::add_stake_limit, SubtensorModule::remove_stake_limit, SubtensorModule::remove_stake_full_limit, - SubtensorModule::set_root_claim_type, } Registration => allow { @@ -2579,6 +2578,24 @@ 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() + } + fn get_validator_weights(hotkey: AccountId32) -> Vec<(NetUid, u16)> { + SubtensorModule::get_validator_root_weights(&hotkey) + } + } + impl subtensor_custom_rpc_runtime_api::ProxyFilterRuntimeApi for Runtime { fn get_proxy_types() -> Vec { get_all_proxy_type_infos() diff --git a/ts-tests/suites/zombienet_staking/02.00-claim-root.test.ts b/ts-tests/suites/zombienet_staking/02.00-claim-root.test.ts deleted file mode 100644 index 0bdfa2011b..0000000000 --- a/ts-tests/suites/zombienet_staking/02.00-claim-root.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { expect, beforeAll } from "vitest"; -import { describeSuite } from "@moonwall/cli"; -import { - forceSetBalance, - generateKeyringPair, - getRootClaimType, - setRootClaimType, - sudoSetLockReductionInterval, -} from "../../utils"; -import { subtensor } from "@polkadot-api/descriptors"; -import type { TypedApi } from "polkadot-api"; - -describeSuite({ - id: "02_set_root_claim_type", - title: "▶ set_root_claim_type extrinsic", - foundationMethods: "zombie", - testCases: ({ it, context, log }) => { - let api: TypedApi; - - beforeAll(async () => { - api = context.papi("Node").getTypedApi(subtensor); - await sudoSetLockReductionInterval(api, 1); - }); - - it({ - id: "T0101", - title: "should set root claim type to Keep", - test: async () => { - const coldkey = generateKeyringPair("sr25519"); - const coldkeyAddress = coldkey.address; - - await forceSetBalance(api, coldkeyAddress); - - // Check initial claim type (default is "Swap") - const claimTypeBefore = await getRootClaimType(api, coldkeyAddress); - log(`Root claim type before: ${claimTypeBefore}`); - - // Set root claim type to Keep - await setRootClaimType(api, coldkey, "Keep"); - - // Verify claim type changed - const claimTypeAfter = await getRootClaimType(api, coldkeyAddress); - log(`Root claim type after: ${claimTypeAfter}`); - - expect(claimTypeAfter).toBe("Keep"); - - log("✅ Successfully set root claim type to Keep."); - }, - }); - - it({ - id: "T0102", - title: "should set root claim type to Swap", - test: async () => { - const coldkey = generateKeyringPair("sr25519"); - const coldkeyAddress = coldkey.address; - - await forceSetBalance(api, coldkeyAddress); - - // First set to Keep so we can verify the change to Swap - await setRootClaimType(api, coldkey, "Keep"); - const claimTypeBefore = await getRootClaimType(api, coldkeyAddress); - log(`Root claim type before: ${claimTypeBefore}`); - expect(claimTypeBefore).toBe("Keep"); - - // Set root claim type to Swap - await setRootClaimType(api, coldkey, "Swap"); - - // Verify claim type changed - const claimTypeAfter = await getRootClaimType(api, coldkeyAddress); - log(`Root claim type after: ${claimTypeAfter}`); - - expect(claimTypeAfter).toBe("Swap"); - - log("✅ Successfully set root claim type to Swap."); - }, - }); - - it({ - id: "T0103", - title: "should set root claim type to KeepSubnets", - test: async () => { - const coldkey = generateKeyringPair("sr25519"); - const coldkeyAddress = coldkey.address; - - await forceSetBalance(api, coldkeyAddress); - - // Check initial claim type (default is "Swap") - const claimTypeBefore = await getRootClaimType(api, coldkeyAddress); - log(`Root claim type before: ${JSON.stringify(claimTypeBefore)}`); - - // Set root claim type to KeepSubnets with specific subnets - const subnetsToKeep = [1, 2]; - await setRootClaimType(api, coldkey, { type: "KeepSubnets", subnets: subnetsToKeep }); - - // Verify claim type changed - const claimTypeAfter = await getRootClaimType(api, coldkeyAddress); - log(`Root claim type after: ${JSON.stringify(claimTypeAfter)}`); - - expect(typeof claimTypeAfter).toBe("object"); - expect((claimTypeAfter as { type: string }).type).toBe("KeepSubnets"); - expect((claimTypeAfter as { subnets: number[] }).subnets).toEqual(subnetsToKeep); - - log("✅ Successfully set root claim type to KeepSubnets."); - }, - }); - }, -}); diff --git a/ts-tests/suites/zombienet_staking/02.01-claim-root.test.ts b/ts-tests/suites/zombienet_staking/02.01-claim-root.test.ts deleted file mode 100644 index 1a86fa4131..0000000000 --- a/ts-tests/suites/zombienet_staking/02.01-claim-root.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, beforeAll } from "vitest"; -import { describeSuite } from "@moonwall/cli"; -import { getNumRootClaims, sudoSetLockReductionInterval, sudoSetNumRootClaims } from "../../utils"; -import { subtensor } from "@polkadot-api/descriptors"; -import type { TypedApi } from "polkadot-api"; - -describeSuite({ - id: "0201_sudo_set_num_root_claims", - title: "▶ sudo_set_num_root_claims extrinsic", - foundationMethods: "zombie", - testCases: ({ it, context, log }) => { - let api: TypedApi; - - beforeAll(async () => { - api = context.papi("Node").getTypedApi(subtensor); - await sudoSetLockReductionInterval(api, 1); - }); - - it({ - id: "T0201", - title: "", - test: async () => { - // Get initial value - const numClaimsBefore = await getNumRootClaims(api); - log(`Num root claims before: ${numClaimsBefore}`); - - // Set new value (different from current) - const newValue = numClaimsBefore + 5n; - await sudoSetNumRootClaims(api, newValue); - - // Verify value changed - const numClaimsAfter = await getNumRootClaims(api); - log(`Num root claims after: ${numClaimsAfter}`); - - expect(numClaimsAfter).toBe(newValue); - - log("✅ Successfully set num root claims."); - }, - }); - }, -}); diff --git a/ts-tests/suites/zombienet_staking/02.02-claim-root.test.ts b/ts-tests/suites/zombienet_staking/02.02-claim-root.test.ts index ff0e3892bf..443e486542 100644 --- a/ts-tests/suites/zombienet_staking/02.02-claim-root.test.ts +++ b/ts-tests/suites/zombienet_staking/02.02-claim-root.test.ts @@ -18,6 +18,7 @@ describeSuite({ foundationMethods: "zombie", testCases: ({ it, context, log }) => { let api: TypedApi; + const ROOT_NETUID = 0; beforeAll(async () => { api = context.papi("Node").getTypedApi(subtensor); @@ -26,38 +27,37 @@ describeSuite({ it({ id: "T0301", - title: "should set root claim threshold for subnet", + title: "should set the ROOT root-claim threshold and reject other netuids", test: async () => { - // Create a subnet to test with - const hotkey = generateKeyringPair("sr25519"); - const coldkey = generateKeyringPair("sr25519"); - const hotkeyAddress = hotkey.address; - const coldkeyAddress = coldkey.address; - - await forceSetBalance(api, hotkeyAddress); - await forceSetBalance(api, coldkeyAddress); - - const netuid = await addNewSubnetwork(api, hotkey, coldkey); - await startCall(api, netuid, coldkey); - // Get initial threshold - const thresholdBefore = await getRootClaimThreshold(api, netuid); + const thresholdBefore = await getRootClaimThreshold(api, ROOT_NETUID); log(`Root claim threshold before: ${thresholdBefore}`); // Set new threshold value (MAX_ROOT_CLAIM_THRESHOLD is 10_000_000) // The value is stored as I96F32 fixed-point with 32 fractional bits const newThreshold = 1_000_000n; - await sudoSetRootClaimThreshold(api, netuid, newThreshold); + await sudoSetRootClaimThreshold(api, ROOT_NETUID, newThreshold); // Verify threshold changed // I96F32 encoding: newThreshold * 2^32 = 1_000_000 * 4294967296 = 4294967296000000 - const thresholdAfter = await getRootClaimThreshold(api, netuid); + const thresholdAfter = await getRootClaimThreshold(api, ROOT_NETUID); log(`Root claim threshold after: ${thresholdAfter}`); const expectedStoredValue = newThreshold * (1n << 32n); // I96F32 encoding expect(thresholdAfter).toBe(expectedStoredValue); - log("✅ Successfully set root claim threshold."); + // Claims only consult the ROOT entry, so setting any other netuid is rejected + // rather than silently storing an inert value. + const hotkey = generateKeyringPair("sr25519"); + const coldkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, hotkey.address); + await forceSetBalance(api, coldkey.address); + const netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + + await expect(sudoSetRootClaimThreshold(api, netuid, newThreshold)).rejects.toThrow(); + + log("✅ ROOT threshold set; non-ROOT netuid rejected."); }, }); }, diff --git a/ts-tests/suites/zombienet_staking/02.03-claim-root.test.ts b/ts-tests/suites/zombienet_staking/02.03-claim-root.test.ts index 37a10bfe65..c3d1658aa8 100644 --- a/ts-tests/suites/zombienet_staking/02.03-claim-root.test.ts +++ b/ts-tests/suites/zombienet_staking/02.03-claim-root.test.ts @@ -6,10 +6,10 @@ import { claimRoot, forceSetBalance, generateKeyringPair, + getBasketClaimed, + getBasketRate, + getBasketShares, getPendingRootAlphaDivs, - getRootClaimable, - getRootClaimed, - getRootClaimType, getStake, getSubnetAlphaIn, getSubnetMovingPrice, @@ -17,7 +17,7 @@ import { getTaoWeight, getTotalHotkeyAlpha, isSubtokenEnabled, - setRootClaimType, + setRootWeights, startCall, sudoSetAdminFreezeWindow, sudoSetEmaPriceHalvingPeriod, @@ -47,7 +47,7 @@ describeSuite({ it({ id: "T0401", - title: "should claim root dividends with Keep type (stake to dynamic subnet)", + title: "should redeem the basket fund to ROOT stake via claim_root", test: async () => { // Setup accounts // - owner1Hotkey/owner1Coldkey: subnet 1 owner @@ -114,9 +114,8 @@ describeSuite({ await sudoSetSubnetMovingAlpha(api, movingAlpha); log("Set SubnetMovingAlpha to 1.0 for fast EMA convergence"); - // Set threshold to 0 to allow claiming any amount - await sudoSetRootClaimThreshold(api, netuid1, 0n); - await sudoSetRootClaimThreshold(api, netuid2, 0n); + // Set threshold to 0 to allow claiming any amount (claims consult the ROOT entry) + await sudoSetRootClaimThreshold(api, ROOT_NETUID, 0n); // Add stake to ROOT subnet for the staker (makes them eligible for root dividends) const rootStakeAmount = tao(100); @@ -128,21 +127,25 @@ describeSuite({ log(`Root stake: ${rootStake}`); expect(rootStake, "Should have stake on root subnet").toBeGreaterThan(0n); + // The validator must set its basket weight vector for dividends to be deposited + // into the fund (otherwise they are recycled). Route them into subnet 1. + await setRootWeights(api, owner1Hotkey, [netuid1], [65535]); + log("Set root weights: 100% to netuid1"); + // Add stake to both dynamic subnets (owner stake to enable emissions flow) const subnetStakeAmount = tao(50); await addStake(api, owner1Coldkey, owner1HotkeyAddress, netuid1, subnetStakeAmount); await addStake(api, owner2Coldkey, owner2HotkeyAddress, netuid2, subnetStakeAmount); log(`Added ${subnetStakeAmount} owner stake to subnets ${netuid1} and ${netuid2}`); - // Get initial stake on subnet 1 for the staker (should be 0) - const stakerSubnetStakeBefore = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, netuid1); - log(`Staker subnet stake before claim: ${stakerSubnetStakeBefore}`); - - // Set root claim type to Keep (keep alpha on subnet instead of swapping to TAO) - await setRootClaimType(api, stakerColdkey, "Keep"); - const claimType = await getRootClaimType(api, stakerColdkeyAddress); - log(`Root claim type: ${claimType}`); - expect(claimType).toBe("Keep"); + // Snapshot the staker's ROOT stake before the claim (redemption pays to root). + const stakerRootStakeBefore = await getStake( + api, + owner1HotkeyAddress, + stakerColdkeyAddress, + ROOT_NETUID + ); + log(`Staker root stake before claim: ${stakerRootStakeBefore}`); // Wait for blocks to: // 1. Allow moving prices to converge (need sum > 1.0 for root_sell_flag) @@ -182,29 +185,35 @@ describeSuite({ const totalHotkeyAlpha1 = await getTotalHotkeyAlpha(api, owner1HotkeyAddress, netuid1); log(`TotalHotkeyAlpha for hotkey1 on netuid1: ${totalHotkeyAlpha1}`); - // Check if there are any claimable dividends - const claimable = await getRootClaimable(api, owner1HotkeyAddress); - const claimableStr = [...claimable.entries()].map(([k, v]) => `[${k}: ${v.toString()}]`).join(", "); - log(`RootClaimable entries for hotkey1: ${claimableStr || "(none)"}`); + // Check the validator's basket fund state + const basketRate = await getBasketRate(api, owner1HotkeyAddress); + const basketShares = await getBasketShares(api, owner1HotkeyAddress); + log(`BasketRate: ${basketRate}, BasketShares: ${basketShares}`); - // Call claim_root to claim dividends for subnet 1 - await claimRoot(api, stakerColdkey, [netuid1]); + // Call claim_root: redeems the staker's owed fund shares to ROOT stake. + await claimRoot(api, stakerColdkey); log("Called claim_root"); - // Get stake on subnet 1 after claim - const stakerSubnetStakeAfter = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, netuid1); - log(`Staker subnet stake after claim: ${stakerSubnetStakeAfter}`); + // Get ROOT stake after claim + const stakerRootStakeAfter = await getStake( + api, + owner1HotkeyAddress, + stakerColdkeyAddress, + ROOT_NETUID + ); + log(`Staker root stake after claim: ${stakerRootStakeAfter}`); - // Check RootClaimed value - const rootClaimed = await getRootClaimed(api, netuid1, owner1HotkeyAddress, stakerColdkeyAddress); - log(`RootClaimed value: ${rootClaimed}`); + // Check the claimed-shares watermark + const basketClaimed = await getBasketClaimed(api, owner1HotkeyAddress, stakerColdkeyAddress); + log(`BasketClaimed value: ${basketClaimed}`); // Verify dividends were claimed - expect(stakerSubnetStakeAfter, "Stake should increase after claiming root dividends").toBeGreaterThan( - stakerSubnetStakeBefore - ); + expect( + stakerRootStakeAfter, + "ROOT stake should increase after claiming root dividends" + ).toBeGreaterThan(stakerRootStakeBefore); log( - `✅ Root claim successful: stake increased from ${stakerSubnetStakeBefore} to ${stakerSubnetStakeAfter}` + `✅ Root claim successful: root stake increased from ${stakerRootStakeBefore} to ${stakerRootStakeAfter}` ); }, }); @@ -264,9 +273,8 @@ describeSuite({ await sudoSetSubnetMovingAlpha(api, movingAlpha); log("Set SubnetMovingAlpha to 1.0 for fast EMA convergence"); - // Set threshold to 0 to allow claiming any amount - await sudoSetRootClaimThreshold(api, netuid1, 0n); - await sudoSetRootClaimThreshold(api, netuid2, 0n); + // Set threshold to 0 to allow claiming any amount (claims consult the ROOT entry) + await sudoSetRootClaimThreshold(api, ROOT_NETUID, 0n); // Add stake to ROOT subnet for the staker const rootStakeAmount = tao(100); @@ -277,18 +285,16 @@ describeSuite({ const rootStakeBefore = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, ROOT_NETUID); log(`Root stake before: ${rootStakeBefore}`); + // Route the validator's basket into subnet 1 so dividends are deposited. + await setRootWeights(api, owner1Hotkey, [netuid1], [65535]); + log("Set root weights: 100% to netuid1"); + // Add stake to both dynamic subnets (owner stake to enable emissions flow) const subnetStakeAmount = tao(50); await addStake(api, owner1Coldkey, owner1HotkeyAddress, netuid1, subnetStakeAmount); await addStake(api, owner2Coldkey, owner2HotkeyAddress, netuid2, subnetStakeAmount); log(`Added ${subnetStakeAmount} owner stake to subnets ${netuid1} and ${netuid2}`); - // Set root claim type to Swap (swap alpha to TAO and add to ROOT stake) - await setRootClaimType(api, stakerColdkey, "Swap"); - const claimType = await getRootClaimType(api, stakerColdkeyAddress); - log(`Root claim type: ${claimType}`); - expect(claimType).toBe("Swap"); - // Wait for blocks const blocksToWait = 25; log(`Waiting for ${blocksToWait} blocks for emissions to accumulate...`); @@ -306,22 +312,22 @@ describeSuite({ const pendingDivs1 = await getPendingRootAlphaDivs(api, netuid1); log(`PendingRootAlphaDivs netuid1: ${pendingDivs1}`); - // Check claimable - const claimable = await getRootClaimable(api, owner1HotkeyAddress); - const claimableStr = [...claimable.entries()].map(([k, v]) => `[${k}: ${v.toString()}]`).join(", "); - log(`RootClaimable entries for hotkey1: ${claimableStr || "(none)"}`); + // Check the validator's basket fund state + const basketRate = await getBasketRate(api, owner1HotkeyAddress); + const basketShares = await getBasketShares(api, owner1HotkeyAddress); + log(`BasketRate: ${basketRate}, BasketShares: ${basketShares}`); - // Call claim_root - with Swap type, dividends are swapped to TAO and added to ROOT stake - await claimRoot(api, stakerColdkey, [netuid1]); - log("Called claim_root with Swap type"); + // Call claim_root - the fund is redeemed to TAO and added to ROOT stake + await claimRoot(api, stakerColdkey); + log("Called claim_root"); // Get ROOT stake after claim const rootStakeAfter = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, ROOT_NETUID); log(`Root stake after claim: ${rootStakeAfter}`); - // Check RootClaimed value - const rootClaimed = await getRootClaimed(api, netuid1, owner1HotkeyAddress, stakerColdkeyAddress); - log(`RootClaimed value: ${rootClaimed}`); + // Check the claimed-shares watermark + const basketClaimed = await getBasketClaimed(api, owner1HotkeyAddress, stakerColdkeyAddress); + log(`BasketClaimed value: ${basketClaimed}`); // With Swap type, ROOT stake should increase (not dynamic subnet stake) expect(rootStakeAfter, "ROOT stake should increase after claiming with Swap type").toBeGreaterThan( @@ -343,12 +349,8 @@ describeSuite({ await forceSetBalance(api, coldkeyAddress); - // Set root claim type to Keep - await setRootClaimType(api, coldkey, "Keep"); - - // Try to claim on a non-existent subnet (should succeed but be a no-op) - // According to Rust tests, claiming on unrelated subnets returns Ok but does nothing - await claimRoot(api, coldkey, [1]); + // Claim with no basket accrued (should succeed but be a no-op) + await claimRoot(api, coldkey); log("✅ claim_root with no dividends executed successfully (no-op)."); }, diff --git a/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts b/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts index 0124bae671..4a75fa93ad 100644 --- a/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts +++ b/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts @@ -5,7 +5,9 @@ import { burnedRegister, forceSetBalance, generateKeyringPair, - getRootClaimable, + getBasketRate, + getBasketShares, + setRootWeights, startCall, sudoSetAdminFreezeWindow, sudoSetEmaPriceHalvingPeriod, @@ -19,13 +21,15 @@ import { } from "../../utils"; import { subtensor } from "@polkadot-api/descriptors"; import type { TypedApi } from "polkadot-api"; +import { rootRegister } from "../../utils/subnet.ts"; import { swapHotkey } from "../../utils/swap.ts"; import { describeSuite } from "@moonwall/cli"; import type { KeyringPair } from "@moonwall/util"; -// Shared setup: creates two subnets, registers oldHotkey on both, -// stakes on ROOT and both subnets, waits for RootClaimable to accumulate. -async function setupTwoSubnetsWithClaimable( +// Shared setup: creates two subnets, registers oldHotkey on both (and on root), points its +// basket weight vector at the subnets, stakes on ROOT and both subnets, then waits for the +// unified basket fund (BasketRate / BasketShares) to accumulate. +async function setupTwoSubnetsWithBasket( api: TypedApi, ROOT_NETUID: number, log: (msg: string) => void @@ -70,12 +74,12 @@ async function setupTwoSubnetsWithClaimable( for (const netuid of [netuid1, netuid2]) { await sudoSetTempo(api, netuid, 1); await sudoSetEmaPriceHalvingPeriod(api, netuid, 1); - await sudoSetRootClaimThreshold(api, netuid, 0n); } + await sudoSetRootClaimThreshold(api, ROOT_NETUID, 0n); await sudoSetSubnetMovingAlpha(api, BigInt(4294967296)); // Register oldHotkey on both subnets so it appears in epoch hotkey_emission - // and receives root_alpha_dividends → RootClaimable on both netuids + // and receives root_alpha_dividends await burnedRegister(api, netuid1, oldHotkey.address, oldHotkeyColdkey); log("oldHotkey registered on netuid1"); await burnedRegister(api, netuid2, oldHotkey.address, oldHotkeyColdkey); @@ -91,15 +95,22 @@ async function setupTwoSubnetsWithClaimable( await addStake(api, owner1Coldkey, owner1Hotkey.address, netuid1, tao(50)); await addStake(api, owner2Coldkey, owner2Hotkey.address, netuid2, tao(50)); - log("Waiting 30 blocks for RootClaimable to accumulate on both subnets..."); + // Register oldHotkey on the root subnet and point its basket weight vector at both + // subnets: without weights, root dividends are recycled and no fund accrues. + await rootRegister(api, oldHotkeyColdkey, oldHotkey.address); + log("oldHotkey registered on root"); + await setRootWeights(api, oldHotkey, [netuid1, netuid2], [32768, 32768]); + log("Set oldHotkey root weights: 50/50 across netuid1/netuid2"); + + log("Waiting 30 blocks for the basket fund to accumulate..."); await waitForBlocks(api, 30); return { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 }; } describeSuite({ - id: "0203_swap_hotkey_root_claimable", - title: "▶ swap_hotkey RootClaimable per-subnet transfer", + id: "0203_swap_hotkey_basket_fund", + title: "▶ swap_hotkey basket fund transfer", foundationMethods: "zombie", testCases: ({ it, context, log }) => { let api: TypedApi; @@ -112,173 +123,125 @@ describeSuite({ it({ id: "T01", - title: "single-subnet swap doesn't move root claimable if it is not root", + title: "single-subnet swap doesn't move the basket fund if it is not root", test: async () => { - const { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 } = await setupTwoSubnetsWithClaimable( + const { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1 } = await setupTwoSubnetsWithBasket( api, ROOT_NETUID, log ); - const claimableMapBefore = await getRootClaimable(api, oldHotkey.address); - log( - `RootClaimable[oldHotkey] before swap: ${ - [...claimableMapBefore.entries()].map(([k, v]) => `netuid${k}=${v}`).join(", ") || "(none)" - }` - ); + const rateBefore = await getBasketRate(api, oldHotkey.address); + const sharesBefore = await getBasketShares(api, oldHotkey.address); + log(`oldHotkey fund before swap: rate=${rateBefore}, shares=${sharesBefore}`); + expect(rateBefore, "oldHotkey should have a basket fund before swap").toBeGreaterThan(0n); + expect(sharesBefore, "oldHotkey should have fund shares before swap").toBeGreaterThan(0n); expect( - claimableMapBefore.get(netuid1) ?? 0n, - "oldHotkey should have RootClaimable on netuid1 before swap" - ).toBeGreaterThan(0n); - expect( - claimableMapBefore.get(netuid2) ?? 0n, - "oldHotkey should have RootClaimable on netuid2 before swap" - ).toBeGreaterThan(0n); - expect( - (await getRootClaimable(api, newHotkey.address)).size, - "newHotkey should have no RootClaimable before swap" - ).toBe(0); + await getBasketRate(api, newHotkey.address), + "newHotkey should have no fund before swap" + ).toBe(0n); // Swap oldHotkey → newHotkey on netuid1 ONLY log(`Swapping oldHotkey → newHotkey on netuid1=${netuid1} only...`); await swapHotkey(api, oldHotkeyColdkey, oldHotkey.address, newHotkey.address, netuid1); log("Swap done"); - const oldAfter = await getRootClaimable(api, oldHotkey.address); - const newAfter = await getRootClaimable(api, newHotkey.address); - - log( - `RootClaimable[oldHotkey] after swap: netuid1=${oldAfter.get(netuid1) ?? 0n}, netuid2=${oldAfter.get(netuid2) ?? 0n}` - ); - log( - `RootClaimable[newHotkey] after swap: netuid1=${newAfter.get(netuid1) ?? 0n}, netuid2=${newAfter.get(netuid2) ?? 0n}` - ); - - expect(newAfter.get(netuid1) ?? 0n, "newHotkey should not have RootClaimable for netuid1").toEqual(0n); + // The fund is tied to the validator's root identity: a non-root swap must not + // move any of it. expect( - oldAfter.get(netuid1) ?? 0n, - "oldHotkey should retain RootClaimable for netuid1" - ).toBeGreaterThan(0n); - + await getBasketRate(api, oldHotkey.address), + "oldHotkey must retain its fund rate" + ).toBe(rateBefore); + expect( + await getBasketRate(api, newHotkey.address), + "newHotkey must have no fund rate" + ).toBe(0n); expect( - oldAfter.get(netuid2) ?? 0n, - "oldHotkey should retain RootClaimable for netuid2" - ).toBeGreaterThan(0n); - expect(newAfter.get(netuid2) ?? 0n, "newHotkey should have no RootClaimable for netuid2").toBe(0n); + await getBasketShares(api, newHotkey.address), + "newHotkey must have no fund shares" + ).toBe(0n); - log( - "✅ Single-subnet swap doesn't transfer RootClaimable for the subnet if it was done for non-root subnet" - ); + log("✅ Non-root single-subnet swap doesn't transfer the basket fund"); }, }); it({ id: "T02", - title: "full swap (no netuid) moves RootClaimable for all subnets to newHotkey", + title: "full swap (no netuid) moves the whole basket fund to newHotkey", test: async () => { - const { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 } = await setupTwoSubnetsWithClaimable( + const { oldHotkey, oldHotkeyColdkey, newHotkey } = await setupTwoSubnetsWithBasket( api, ROOT_NETUID, log ); - const claimableMapBefore = await getRootClaimable(api, oldHotkey.address); - log( - `RootClaimable[oldHotkey] before swap: ${ - [...claimableMapBefore.entries()].map(([k, v]) => `netuid${k}=${v}`).join(", ") || "(none)" - }` - ); + const rateBefore = await getBasketRate(api, oldHotkey.address); + const sharesBefore = await getBasketShares(api, oldHotkey.address); + log(`oldHotkey fund before swap: rate=${rateBefore}, shares=${sharesBefore}`); - expect( - claimableMapBefore.get(netuid1) ?? 0n, - "oldHotkey should have RootClaimable on netuid1 before swap" - ).toBeGreaterThan(0n); - expect( - claimableMapBefore.get(netuid2) ?? 0n, - "oldHotkey should have RootClaimable on netuid2 before swap" - ).toBeGreaterThan(0n); + expect(rateBefore, "oldHotkey should have a basket fund before swap").toBeGreaterThan(0n); + expect(sharesBefore, "oldHotkey should have fund shares before swap").toBeGreaterThan(0n); // Full swap — no netuid log("Swapping oldHotkey → newHotkey on ALL subnets..."); await swapHotkey(api, oldHotkeyColdkey, oldHotkey.address, newHotkey.address); log("Swap done"); - const oldAfter = await getRootClaimable(api, oldHotkey.address); - const newAfter = await getRootClaimable(api, newHotkey.address); - - log( - `RootClaimable[oldHotkey] after swap: netuid1=${oldAfter.get(netuid1) ?? 0n}, netuid2=${oldAfter.get(netuid2) ?? 0n}` - ); - log( - `RootClaimable[newHotkey] after swap: netuid1=${newAfter.get(netuid1) ?? 0n}, netuid2=${newAfter.get(netuid2) ?? 0n}` - ); - - expect(newAfter.get(netuid1) ?? 0n, "newHotkey should have RootClaimable for netuid1").toBeGreaterThan( - 0n - ); - expect(newAfter.get(netuid2) ?? 0n, "newHotkey should have RootClaimable for netuid2").toBeGreaterThan( - 0n - ); - - expect(oldAfter.get(netuid1) ?? 0n, "oldHotkey should have no RootClaimable for netuid1").toBe(0n); - expect(oldAfter.get(netuid2) ?? 0n, "oldHotkey should have no RootClaimable for netuid2").toBe(0n); + expect( + await getBasketRate(api, newHotkey.address), + "newHotkey must have oldHotkey's fund rate" + ).toBe(rateBefore); + expect( + await getBasketShares(api, newHotkey.address), + "newHotkey must have oldHotkey's fund shares" + ).toBe(sharesBefore); + expect(await getBasketRate(api, oldHotkey.address), "oldHotkey must have no fund left").toBe(0n); + expect( + await getBasketShares(api, oldHotkey.address), + "oldHotkey must have no shares left" + ).toBe(0n); - log("✅ Full swap correctly transferred RootClaimable for both subnets to newHotkey"); + log("✅ Full swap correctly transferred the whole basket fund to newHotkey"); }, }); it({ id: "T03", - title: "single-subnet swap moves root claimable if it is root", + title: "single-subnet swap moves the basket fund if it is root", test: async () => { - const { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 } = await setupTwoSubnetsWithClaimable( + const { oldHotkey, oldHotkeyColdkey, newHotkey } = await setupTwoSubnetsWithBasket( api, ROOT_NETUID, log ); - const claimableMapBefore = await getRootClaimable(api, oldHotkey.address); - log( - `RootClaimable[oldHotkey] before swap: ${ - [...claimableMapBefore.entries()].map(([k, v]) => `netuid${k}=${v}`).join(", ") || "(none)" - }` - ); + const rateBefore = await getBasketRate(api, oldHotkey.address); + const sharesBefore = await getBasketShares(api, oldHotkey.address); + log(`oldHotkey fund before swap: rate=${rateBefore}, shares=${sharesBefore}`); - expect( - claimableMapBefore.get(netuid1) ?? 0n, - "oldHotkey should have RootClaimable on netuid1 before swap" - ).toBeGreaterThan(0n); - expect( - claimableMapBefore.get(netuid2) ?? 0n, - "oldHotkey should have RootClaimable on netuid2 before swap" - ).toBeGreaterThan(0n); + expect(rateBefore, "oldHotkey should have a basket fund before swap").toBeGreaterThan(0n); + expect(sharesBefore, "oldHotkey should have fund shares before swap").toBeGreaterThan(0n); log("Swapping oldHotkey → newHotkey for root subnet..."); await swapHotkey(api, oldHotkeyColdkey, oldHotkey.address, newHotkey.address, 0); log("Swap done"); - const oldAfter = await getRootClaimable(api, oldHotkey.address); - const newAfter = await getRootClaimable(api, newHotkey.address); - - log( - `RootClaimable[oldHotkey] after swap: netuid1=${oldAfter.get(netuid1) ?? 0n}, netuid2=${oldAfter.get(netuid2) ?? 0n}` - ); - log( - `RootClaimable[newHotkey] after swap: netuid1=${newAfter.get(netuid1) ?? 0n}, netuid2=${newAfter.get(netuid2) ?? 0n}` - ); - - expect(newAfter.get(netuid1) ?? 0n, "newHotkey should have RootClaimable for netuid1").toBeGreaterThan( - 0n - ); - expect(newAfter.get(netuid2) ?? 0n, "newHotkey should have RootClaimable for netuid2").toBeGreaterThan( - 0n - ); - - expect(oldAfter.get(netuid1) ?? 0n, "oldHotkey should have no RootClaimable for netuid1").toBe(0n); - expect(oldAfter.get(netuid2) ?? 0n, "oldHotkey should have no RootClaimable for netuid2").toBe(0n); + expect( + await getBasketRate(api, newHotkey.address), + "newHotkey must have oldHotkey's fund rate" + ).toBe(rateBefore); + expect( + await getBasketShares(api, newHotkey.address), + "newHotkey must have oldHotkey's fund shares" + ).toBe(sharesBefore); + expect(await getBasketRate(api, oldHotkey.address), "oldHotkey must have no fund left").toBe(0n); + expect( + await getBasketShares(api, oldHotkey.address), + "oldHotkey must have no shares left" + ).toBe(0n); - log("✅ Single swap correctly transferred RootClaimable if it is done for root subnet"); + log("✅ Root swap correctly transferred the basket fund to newHotkey"); }, }); }, diff --git a/ts-tests/utils/staking.ts b/ts-tests/utils/staking.ts index 4efdc19802..96eb961880 100644 --- a/ts-tests/utils/staking.ts +++ b/ts-tests/utils/staking.ts @@ -220,58 +220,12 @@ export async function swapStakeLimit( await waitForTransactionWithRetry(api, tx, coldkey, "swap_stake_limit"); } -export type RootClaimType = "Swap" | "Keep" | { type: "KeepSubnets"; subnets: number[] }; - -export async function getRootClaimType(api: TypedApi, coldkey: string): Promise { - const result = await api.query.SubtensorModule.RootClaimType.getValue(coldkey); - if (result.type === "KeepSubnets") { - return { type: "KeepSubnets", subnets: result.value.subnets as number[] }; - } - return result.type as "Swap" | "Keep"; -} - -export async function setRootClaimType( - api: TypedApi, - coldkey: KeyringPair, - claimType: RootClaimType -): Promise { - let newRootClaimType; - if (typeof claimType === "string") { - newRootClaimType = { type: claimType, value: undefined }; - } else { - newRootClaimType = { type: "KeepSubnets", value: { subnets: claimType.subnets } }; - } - const tx = api.tx.SubtensorModule.set_root_claim_type({ - new_root_claim_type: newRootClaimType, - }); - await waitForTransactionWithRetry(api, tx, coldkey, "set_root_claim_type"); -} - -export async function claimRoot( - api: TypedApi, - coldkey: KeyringPair, - subnets: number[] -): Promise { - const tx = api.tx.SubtensorModule.claim_root({ - subnets: subnets, - }); +export async function claimRoot(api: TypedApi, coldkey: KeyringPair): Promise { + // Fund-level redemption: no per-subnet selection. + const tx = api.tx.SubtensorModule.claim_root(); await waitForTransactionWithRetry(api, tx, coldkey, "claim_root"); } -export async function getNumRootClaims(api: TypedApi): Promise { - return await api.query.SubtensorModule.NumRootClaim.getValue(); -} - -export async function sudoSetNumRootClaims(api: TypedApi, newValue: bigint): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); - const internalCall = api.tx.SubtensorModule.sudo_set_num_root_claims({ - new_value: newValue, - }); - const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); - await waitForTransactionWithRetry(api, tx, alice, "sudo_set_num_root_claims"); -} - export async function getRootClaimThreshold(api: TypedApi, netuid: number): Promise { return await api.query.SubtensorModule.RootClaimableThreshold.getValue(netuid); } @@ -319,6 +273,8 @@ export async function waitForBlocks(api: TypedApi, numBlocks: } } +/// LEGACY per-subnet claimable rates; drained by the seed migration. Kept only for +/// migration-era assertions. export async function getRootClaimable(api: TypedApi, hotkey: string): Promise> { const result = await api.query.SubtensorModule.RootClaimable.getValue(hotkey); const claimableMap = new Map(); @@ -328,6 +284,7 @@ export async function getRootClaimable(api: TypedApi, hotkey: return claimableMap; } +/// LEGACY per-subnet claimed watermarks; drained by the seed migration. export async function getRootClaimed( api: TypedApi, netuid: number, @@ -337,6 +294,41 @@ export async function getRootClaimed( return await api.query.SubtensorModule.RootClaimed.getValue(netuid, hotkey, coldkey); } +/// Sets a root validator's beta-basket weight vector (the distribution its root dividends are +/// deployed into). Signed by the validator hotkey; requires a root UID. +export async function setRootWeights( + api: TypedApi, + hotkey: KeyringPair, + dests: number[], + weights: number[] +): Promise { + const tx = api.tx.SubtensorModule.set_root_weights({ + dests: dests, + weights: weights, + version_key: 0n, + }); + await waitForTransactionWithRetry(api, tx, hotkey, "set_root_weights"); +} + +/// A validator's unified fund-shares-per-root-stake accumulator (I96F32 raw bits). +export async function getBasketRate(api: TypedApi, hotkey: string): Promise { + return await api.query.SubtensorModule.BasketRate.getValue(hotkey); +} + +/// A validator's total outstanding basket fund shares. +export async function getBasketShares(api: TypedApi, hotkey: string): Promise { + return await api.query.SubtensorModule.BasketShares.getValue(hotkey); +} + +/// A staker's claimed-shares watermark on a validator's fund. +export async function getBasketClaimed( + api: TypedApi, + hotkey: string, + coldkey: string +): Promise { + return await api.query.SubtensorModule.BasketClaimed.getValue(hotkey, coldkey); +} + export async function isSubtokenEnabled(api: TypedApi, netuid: number): Promise { return await api.query.SubtensorModule.SubtokenEnabled.getValue(netuid); }