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