diff --git a/docs/guides/root-reborn.mdx b/docs/guides/root-reborn.mdx index 292d67d139..401628d17f 100644 --- a/docs/guides/root-reborn.mdx +++ b/docs/guides/root-reborn.mdx @@ -239,8 +239,11 @@ root-stakes to. deadline and nothing expires. - **Claim fee.** The inclusion fee scales with how many ALPHA types the basket holds. Both root-claim calls reserve a conservative 256-unit work - envelope at inclusion, independent of how many networks currently exist, - and refund the unused part after the claim. The amount you actually spend + envelope on every chain and refund the unused part after the claim. Admission + counts only root-relevant validator hotkeys plus their stored basket rows; + unrelated subnet stakes are filtered instead of being multiplied by the total + network count. Classifying the staking-hotkey vector is separately capped at + 256 relationships. The amount you actually spend follows the holdings scanned and redeemed (around τ0.057 on a full 128-holding basket). `btcli root claim --dry-run` shows reserved versus spent, compares the diff --git a/docs/guides/staking.mdx b/docs/guides/staking.mdx index 155868329b..279c1cabc5 100644 --- a/docs/guides/staking.mdx +++ b/docs/guides/staking.mdx @@ -190,8 +190,11 @@ btcli stake remove --netuid 0 --hotkey 5F... --amount all --claim # claim all, ``` The claim fee scales with how many ALPHA types are in the basket. Both -root-claim calls reserve a conservative 256-unit work envelope at inclusion, -independent of the current network count, and refund the unused part after. +root-claim calls reserve a conservative 256-unit work envelope on every chain +and refund the unused part after. Admission counts only root-relevant validator +hotkeys plus their stored basket rows; unrelated subnet stakes are filtered +instead of being multiplied by the total network count. Classifying the +staking-hotkey vector is separately capped at 256 relationships. You actually spend according to the holdings scanned and redeemed (around τ0.057 on a full 128-holding basket). `--dry-run` and the confirm step show reserved versus spent, warn if that spent fee exceeds accrued yield, and diff --git a/pallets/subtensor/src/benchmarks/benchmarks.rs b/pallets/subtensor/src/benchmarks/benchmarks.rs index 0c137a6363..b21a5ca106 100644 --- a/pallets/subtensor/src/benchmarks/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks/benchmarks.rs @@ -2051,8 +2051,10 @@ mod pallet_benchmarks { } #[benchmark] - fn claim_root(h: Linear<1, { crate::MAX_ROOT_CLAIM_WORK }>) { - // Coldkey-wide claim: `h` validator hotkeys, one holding each. `subnets` is ignored. + fn claim_root(h: Linear<1, { crate::MAX_ROOT_CLAIM_WORK / 2 }>) { + // Coldkey-wide claim: `h` validator hotkeys, one holding each. Each validator consumes + // two admission units (the selected hotkey plus its basket row), so the benchmark's + // executable maximum is half the combined work envelope. `subnets` is ignored. let coldkey: T::AccountId = whitelisted_caller(); let owner_coldkey: T::AccountId = account("claim_owner_cold", 0, 0); let owner_hotkey: T::AccountId = account("claim_owner_hot", 0, 1); diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 262bd21626..a3352b97b9 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -72,10 +72,8 @@ pub const MIN_ALPHA_LOW: u16 = 1_639; pub const MAX_ROOT_CLAIM_THRESHOLD: u64 = 10_000_000; /// Benchmark upper bound and admission envelope for `claim_root` / -/// `claim_root_scan` (Linear<1, N>). Weight calculation cannot walk storage, -/// so both claim paths reserve this many units and refuse work that would -/// exceed the envelope. Post-dispatch weight is refunded to the work -/// actually performed. +/// `claim_root_scan` (`Linear<1, N>`). Both claim paths reserve this many units +/// and refund unused weight after dispatch. pub const MAX_ROOT_CLAIM_WORK: u32 = 256; /// Minimum number of positive destination weights required by `set_root_weights`. Softened diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 33184cc22b..d9dbb58a61 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -1966,11 +1966,8 @@ mod dispatches { /// # Events /// * `RootClaimed`: On successfully claiming the root emissions for a coldkey. #[pallet::call_index(121)] - // Signer is not in the call data, so admission uses the conservative - // MAX_ROOT_CLAIM_WORK envelope. Execution refuses a fat coldkey that - // would exceed it — use claim_root_with_hotkey per validator. #[pallet::weight( - ::WeightInfo::claim_root(Pallet::::root_claim_declared_work()) + Pallet::::root_claim_declared_weight() )] pub fn claim_root( origin: OriginFor, @@ -1979,7 +1976,13 @@ mod dispatches { let coldkey: T::AccountId = ensure_signed(origin)?; let _ = subnets; // ignored: basket claims are fund-level, not per-subnet - let hotkeys = StakingHotkeys::::get(&coldkey); + let staking_hotkeys = StakingHotkeys::::get(&coldkey); + let selection_scanned = u32::try_from(staking_hotkeys.len()).unwrap_or(u32::MAX); + ensure!( + selection_scanned <= Self::root_claim_declared_work(), + Error::::RootClaimTooHeavy + ); + let hotkeys = Self::root_claim_hotkeys(&coldkey, staking_hotkeys); ensure!( Self::root_claim_fits_declared_budget(&hotkeys), Error::::RootClaimTooHeavy @@ -1988,7 +1991,7 @@ mod dispatches { let outcome = Self::do_root_claim(coldkey.clone(), hotkeys)?; Self::maybe_add_coldkey_index(&coldkey); - let weight = Self::root_claim_actual_weight(hotkey_count, &outcome); + let weight = Self::root_claim_actual_weight(hotkey_count, selection_scanned, &outcome); Ok((Some(weight), Pays::Yes).into()) } @@ -2008,7 +2011,7 @@ mod dispatches { /// * `RootClaimed`: On successfully claiming the root emissions for this coldkey+hotkey. #[pallet::call_index(148)] #[pallet::weight( - ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK) + Pallet::::root_claim_declared_weight() )] pub fn claim_root_with_hotkey( origin: OriginFor, @@ -2023,7 +2026,7 @@ mod dispatches { let outcome = Self::do_root_claim(coldkey.clone(), vec![hotkey])?; Self::maybe_add_coldkey_index(&coldkey); - let weight = Self::root_claim_actual_weight(1, &outcome); + let weight = Self::root_claim_actual_weight(1, 0, &outcome); Ok((Some(weight), Pays::Yes).into()) } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 17ab5e17ec..cd757ee821 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -361,8 +361,8 @@ mod errors { /// (dividends accumulate in place) until weight setting is switched on by /// governance or a later upgrade. RootWeightSettingDisabled, - /// Coldkey-wide `claim_root` would process more work units than the - /// pre-dispatch envelope ([`crate::MAX_ROOT_CLAIM_WORK`]). Use + /// A root claim would process more root hotkeys and basket rows than the + /// fixed admission envelope. Use /// `claim_root_with_hotkey` per validator so admission weight matches /// the holdings actually walked. RootClaimTooHeavy, diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 21083cee07..8cd23c6628 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -289,20 +289,21 @@ mod hooks { ); } - // StakingHotkeys cleanup depends on storage GC having removed zero legacy Alpha rows. - // It is otherwise independent and does not block or gate any runtime operation. - let storage_bloat_cursor_read = T::DbWeight::get().reads(1); - let storage_bloat_in_progress = if !seed_in_progress + // StakingHotkeys cleanup depends on storage GC having completed all of its targets. + // Gate on the positive completion marker rather than cursor absence so an absent or + // unexpectedly removed cursor cannot start the dependent cleanup early. + let storage_bloat_completion_read = T::DbWeight::get().reads(1); + let storage_bloat_complete = if !seed_in_progress && weight - .saturating_add(storage_bloat_cursor_read) + .saturating_add(storage_bloat_completion_read) .all_lt(limit) { - weight.saturating_accrue(storage_bloat_cursor_read); - migrations::migrate_storage_bloat_v2::StorageBloatCleanupMigration::::exists() + weight.saturating_accrue(storage_bloat_completion_read); + migrations::migrate_storage_bloat_v2::storage_bloat_cleanup_complete::() } else { - true + false }; - if !seed_in_progress && !storage_bloat_in_progress && weight.all_lt(limit) { + if !seed_in_progress && storage_bloat_complete && weight.all_lt(limit) { weight.saturating_accrue( migrations::migrate_cleanup_staking_hotkeys::continue_staking_hotkeys_cleanup::< T, diff --git a/pallets/subtensor/src/migrations/migrate_cleanup_staking_hotkeys.rs b/pallets/subtensor/src/migrations/migrate_cleanup_staking_hotkeys.rs index 562ebfa4bf..6892cb8aab 100644 --- a/pallets/subtensor/src/migrations/migrate_cleanup_staking_hotkeys.rs +++ b/pallets/subtensor/src/migrations/migrate_cleanup_staking_hotkeys.rs @@ -6,7 +6,9 @@ use scale_info::prelude::string::String; use sp_std::collections::btree_set::BTreeSet; use sp_std::vec::Vec; -pub const MIGRATION_NAME: &[u8] = b"migrate_cleanup_staking_hotkeys"; +// Fresh marker reruns the same bounded cleanup after share-pool dust canonicalization. The +// original `migrate_cleanup_staking_hotkeys` pass may already be marked complete on-chain. +pub const MIGRATION_NAME: &[u8] = b"migrate_cleanup_staking_hotkeys_v2"; /// Persistent progress for the bounded `StakingHotkeys` cleanup. /// @@ -48,11 +50,10 @@ fn row_load_weight() -> Weight { /// A conservative keep predicate for one hotkey/coldkey relationship. /// -/// Any stored share row is retained, including a zero-valued legacy row. The storage-bloat -/// migration runs first and clears zero legacy rows, while treating an unexpected remaining row -/// as live makes this cleanup fail safe. A basket watermark is also sufficient to retain the -/// relationship because zero-root-stake claimants still need to be discoverable by claims and -/// coldkey swaps. +/// Any stored non-zero share row is retained. The storage-bloat migration runs first and clears +/// exact-zero Alpha and AlphaV2 rows; treating an unexpected remaining row as live keeps this +/// cleanup fail safe. A basket watermark is also sufficient to retain the relationship because +/// zero-root-stake claimants still need to be discoverable by claims and coldkey swaps. fn relationship_must_remain(hotkey: &T::AccountId, coldkey: &T::AccountId) -> bool { AlphaV2::::iter_prefix((hotkey, coldkey)) .next() diff --git a/pallets/subtensor/src/migrations/migrate_storage_bloat_v2.rs b/pallets/subtensor/src/migrations/migrate_storage_bloat_v2.rs index 4e61dfeda9..f00e5cf8ad 100644 --- a/pallets/subtensor/src/migrations/migrate_storage_bloat_v2.rs +++ b/pallets/subtensor/src/migrations/migrate_storage_bloat_v2.rs @@ -6,7 +6,9 @@ use scale_info::prelude::string::String; use sp_io::{hashing::twox_128, storage}; use sp_std::vec::Vec; -const MIGRATION_NAME: &[u8] = b"migrate_storage_bloat_v2"; +// Fresh marker reruns the bounded GC with the AlphaV2 zero-row target added below. The original +// v2 pass may already be marked complete on-chain. +const MIGRATION_NAME: &[u8] = b"migrate_storage_bloat_v3"; #[derive(Clone, Copy, PartialEq, Eq)] enum CleanupMode { @@ -90,6 +92,12 @@ const TARGETS: &[CleanupTarget] = &[ storage: "StakingHotkeys", mode: CleanupMode::ClearIfZero, }, + // Appended so an interrupted v2 cursor keeps the same target indices after upgrade. + CleanupTarget { + pallet: "SubtensorModule", + storage: "AlphaV2", + mode: CleanupMode::ClearIfZero, + }, ]; /// Persistent progress for the bounded storage cleanup. @@ -109,6 +117,14 @@ pub struct StorageBloatCleanupProgress { pub type StorageBloatCleanupMigration = StorageValue, StorageBloatCleanupProgress, OptionQuery>; +/// Returns true only after the current storage-bloat sweep has completed successfully. +/// +/// Dependants must use this marker instead of cursor absence: a missing cursor can also mean +/// that the migration was never scheduled or that its progress state was removed unexpectedly. +pub fn storage_bloat_cleanup_complete() -> bool { + HasMigrationRun::::get(MIGRATION_NAME) +} + fn storage_prefix(pallet: &str, item: &str) -> Vec { [twox_128(pallet.as_bytes()), twox_128(item.as_bytes())].concat() } diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 83d6a7030b..83930c0db7 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -4,7 +4,7 @@ use frame_support::storage::{TransactionOutcome, with_transaction}; use frame_support::weights::{Weight, WeightMeter}; use sp_core::Get; use sp_runtime::DispatchError; -use sp_runtime::traits::AccountIdConversion; +use sp_runtime::traits::{AccountIdConversion, Zero}; use sp_std::collections::btree_map::BTreeMap; use sp_std::collections::btree_set::BTreeSet; use substrate_fixed::types::I96F32; @@ -636,21 +636,6 @@ impl Pallet { return Ok(outcome); } - // A claim that pays on some holdings but floors a valuable holding to take == 0 must - // not settle: burning all owed shares would forfeit that holding to remaining - // shareholders (e.g. 99/100 of a 1-alpha position → floor 0, then the leftover - // 1-share holder owns the whole unit). Same posture as the all-zero-take rollback - // below — leave the watermark untouched. Worthless rows (realizable 0) are ignored. - for (_, slot_alpha, slot_value, _) in valued_holdings.iter() { - let alpha = slot_alpha.to_u64(); - if alpha == 0 { - continue; - } - if Self::mul_div_u64(alpha, owed_shares, shares_total) == 0 && *slot_value > 0 { - return Ok(outcome); - } - } - let escrow = Self::get_beta_escrow_account_id(); // Redeemed slots are counted outside the transaction: a rolled-back redemption @@ -666,8 +651,25 @@ impl Pallet { let mut written_off: u32 = 0; for (netuid, slot_alpha, slot_value, terminal_garbage) in valued_holdings.iter() { + let slot_entitlement = + Self::basket_payout_from(owed_shares, *slot_value, shares_total); // This staker's pro-rata slice of the holding: slot_alpha * owed / P. - let take: u64 = Self::mul_div_u64(slot_alpha.to_u64(), owed_shares, shares_total); + let proportional_take = + Self::mul_div_u64(slot_alpha.to_u64(), owed_shares, shares_total); + // A high-value alpha row can owe at least one rao even when its proportional + // alpha slice floors to zero. Sell one atomic alpha unit, pay no more than the + // marked entitlement below, and retain the sale surplus as fund root cash. + // Root is already denominated in rao, so take == entitlement there; terminal + // rows have no realizable entitlement and keep the ordinary floor. + let take = if proportional_take == 0 + && slot_entitlement > 0 + && !netuid.is_root() + && !terminal_garbage + { + 1 + } else { + proportional_take + }; if take == 0 { continue; } @@ -729,8 +731,6 @@ impl Pallet { // fraction, so pay only the priced entitlement and retain the surplus as // fund cash. Otherwise a permissionless deposit followed by a claim can // extract the difference from earlier holders. - let slot_entitlement = - Self::basket_payout_from(owed_shares, *slot_value, shares_total); let realized_tao = tao.to_u64(); // A final claimant has no remaining holders to retain a surplus for. Give // them every realized rao so no root cash is stranded behind zero shares. @@ -895,51 +895,82 @@ impl Pallet { swept } - /// Live existing-network count (including root). Ghost `NetworksAdded=false` - /// leftovers are excluded so they cannot inflate the single-hotkey quote. - pub(crate) fn root_claim_existing_networks() -> u32 { - (Self::get_all_subnet_netuids().len() as u32).max(1) - } - - /// Pre-dispatch work units for both claim paths. Weight calculation must - /// stay storage-independent (no `NetworksAdded` or basket walks here); - /// execution then refuses work that would exceed this envelope. + /// Fixed admission budget for both claim paths. pub(crate) fn root_claim_declared_work() -> u32 { crate::MAX_ROOT_CLAIM_WORK } - /// True when a coldkey-wide claim's reachable work (hotkeys × existing - /// networks, and the actual basket rows) fits the admission envelope. + /// Pre-dispatch weight for both independently bounded dimensions: full claim work and + /// scan-only work. + pub(crate) fn root_claim_declared_weight() -> Weight { + let limit = Self::root_claim_declared_work(); + ::WeightInfo::claim_root(limit).saturating_add( + ::WeightInfo::claim_root_scan(limit), + ) + } + + /// Hotkeys relevant to a coldkey-wide root claim. Ordinary subnet-only staking hotkeys + /// are deliberately excluded. A negative basket watermark keeps an unstaked claimant + /// eligible because it encodes shares which still need to be redeemed. + pub(crate) fn root_claim_hotkeys( + coldkey: &T::AccountId, + staking_hotkeys: Vec, + ) -> Vec { + staking_hotkeys + .into_iter() + .filter(|hotkey| { + !Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT) + .is_zero() + || BasketClaimed::::get(hotkey, coldkey) < 0 + }) + .collect() + } + + /// True when the hotkeys plus the basket storage rows the claim will scan fit the fixed + /// admission envelope. Count raw Alpha/AlphaV2 rows so legacy duplicates and malformed + /// zero rows are charged conservatively, and stop as soon as the bound is exceeded. pub(crate) fn root_claim_fits_declared_budget(hotkeys: &[T::AccountId]) -> bool { let budget = Self::root_claim_declared_work(); - let networks = Self::root_claim_existing_networks(); - let hotkey_count = hotkeys.len() as u32; - if hotkey_count.saturating_mul(networks) > budget { + let mut work = u32::try_from(hotkeys.len()).unwrap_or(u32::MAX); + if work > budget { return false; } - let mut rows: u32 = 0; + + let escrow = Self::get_beta_escrow_account_id(); for hotkey in hotkeys { - rows = rows.saturating_add(Self::get_basket_holdings(hotkey).len() as u32); - if rows > budget { - return false; + for _ in Alpha::::iter_prefix((hotkey, &escrow)) { + work = work.saturating_add(1); + if work > budget { + return false; + } + } + for _ in AlphaV2::::iter_prefix((hotkey, &escrow)) { + work = work.saturating_add(1); + if work > budget { + return false; + } } } true } - /// Actual post-dispatch weight of a root claim: full benchmark units for the slots - /// that did real work (redeemed or swept — a swap plus stake writes each, floored at - /// the hotkey count so walking empty hotkeys stays covered) plus the cheap per-row - /// scan cost for holdings that were only valued. This is what lets a fund's claim fee - /// decay as dust rows are consolidated, and makes a below-threshold no-op cost a scan - /// instead of a full claim. Work above [`crate::MAX_ROOT_CLAIM_WORK`] is - /// refused at dispatch (`RootClaimTooHeavy`) rather than admitted cheaply. + /// Actual post-dispatch weight of a root claim: full benchmark units for relationships + /// classified and slots that did real work (redeemed or swept — a swap plus stake writes + /// each, floored at the selected hotkey count) plus the cheap per-row scan cost for holdings + /// that were only valued. This is what lets a fund's claim fee decay as dust rows are + /// consolidated, and makes a below-threshold no-op cost a scan instead of a full claim. + /// Work above the fixed admission budget + /// is refused at dispatch (`RootClaimTooHeavy`) rather than admitted cheaply. pub(crate) fn root_claim_actual_weight( hotkey_count: u32, + selection_scanned: u32, outcome: &RootClaimOutcome, ) -> Weight { let active = hotkey_count .max(outcome.realized.saturating_add(outcome.swept)) + // Classifying a StakingHotkeys relationship reads the position's share-pool state + // and basket watermark. Price it conservatively as a full hotkey unit. + .max(selection_scanned) .max(1); let scanned = outcome.rows.saturating_sub(outcome.realized); ::WeightInfo::claim_root(active).saturating_add( diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index c5cfb2e707..8b9dd9b7d8 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -3,12 +3,12 @@ use crate::tests::mock::*; use crate::weights::WeightInfo; use crate::{ - BasketClaimed, BasketRate, BasketRedeemedTao, BasketShares, BurnIncreaseMult, - DefaultMinRootClaimAmount, Error, Keys, MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, - NumStakingColdkeys, PendingBasketDeposits, RootAlphaDividendsPerSubnet, RootClaimableThreshold, - StakingColdkeys, StakingColdkeysByIndex, StakingHotkeys, SubnetAlphaIn, SubnetMovingPrice, - SubnetOwnerHotkey, SubnetProtocolFlow, SubnetTAO, SubnetworkN, Tempo, TotalStake, Uids, - Weights, + AlphaV2, BasketClaimed, BasketRate, BasketRedeemedTao, BasketShares, BurnIncreaseMult, + DefaultMinRootClaimAmount, Error, Keys, MAX_ROOT_CLAIM_THRESHOLD, MAX_ROOT_CLAIM_WORK, + NetworksAdded, NumStakingColdkeys, PendingBasketDeposits, RootAlphaDividendsPerSubnet, + RootClaimableThreshold, StakingColdkeys, StakingColdkeysByIndex, StakingHotkeys, SubnetAlphaIn, + SubnetMovingPrice, SubnetOwnerHotkey, SubnetProtocolFlow, SubnetTAO, SubnetworkN, Tempo, + TotalStake, Uids, Weights, }; use approx::assert_abs_diff_eq; use frame_support::dispatch::{DispatchClass, GetDispatchInfo, RawOrigin}; @@ -231,23 +231,23 @@ fn test_claim_root_declared_weight_covers_bounded_work() { subnets: subnets.clone(), }); let declared_weight = call.get_dispatch_info().call_weight; - // Coldkey-wide claim_root cannot see the signer in call data, so it - // reserves the conservative MAX_ROOT_CLAIM_WORK envelope. assert_eq!( SubtensorModule::root_claim_declared_work(), - crate::MAX_ROOT_CLAIM_WORK + MAX_ROOT_CLAIM_WORK ); - let envelope = ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK); + let envelope = ::WeightInfo::claim_root(MAX_ROOT_CLAIM_WORK) + .saturating_add(::WeightInfo::claim_root_scan( + MAX_ROOT_CLAIM_WORK, + )); assert!( declared_weight.all_gte(envelope), "declared {declared_weight:?} must cover the {envelope:?} admission envelope" ); - // Ghost NetworksAdded=false keys must not inflate the single-hotkey quote. - let existing = SubtensorModule::root_claim_existing_networks(); - let ghost = NetUid::from(u16::MAX); - NetworksAdded::::insert(ghost, false); - assert_eq!(SubtensorModule::root_claim_existing_networks(), existing); - assert!(existing < crate::MAX_ROOT_CLAIM_WORK); + // Network count is not part of admission; only relevant hotkeys and stored basket rows + // consume the fixed envelope. + for raw_netuid in 1..=MAX_ROOT_CLAIM_WORK as u16 { + NetworksAdded::::insert(NetUid::from(raw_netuid), true); + } let actual_weight = SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), subnets) .expect("claim succeeds") .actual_weight @@ -270,11 +270,13 @@ fn test_claim_root_declared_weight_covers_bounded_work() { fn test_claim_root_rejects_work_above_declared_budget() { new_test_ext(1).execute_with(|| { let coldkey = U256::from(1001); - let networks = SubtensorModule::root_claim_existing_networks(); - let too_many = (crate::MAX_ROOT_CLAIM_WORK / networks.max(1)).saturating_add(1); - let hotkeys: Vec = (0..too_many) + let hotkeys: Vec = (0..=MAX_ROOT_CLAIM_WORK) .map(|i| U256::from(2_000u32.saturating_add(i))) .collect(); + assert!(!SubtensorModule::root_claim_fits_declared_budget(&hotkeys)); + + // Candidate classification is independently bounded even if every relationship would + // subsequently be filtered as non-root. StakingHotkeys::::insert(coldkey, hotkeys); assert_noop!( SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), BTreeSet::new()), @@ -283,6 +285,73 @@ fn test_claim_root_rejects_work_above_declared_budget() { }); } +#[test] +fn test_claim_root_ignores_network_count_and_bounds_actual_basket_rows() { + new_test_ext(1).execute_with(|| { + for raw_netuid in 1..=(MAX_ROOT_CLAIM_WORK as u16 + 10) { + NetworksAdded::::insert(NetUid::from(raw_netuid), true); + } + + let hotkey = U256::from(1002); + let escrow = SubtensorModule::get_beta_escrow_account_id(); + assert!(SubtensorModule::root_claim_fits_declared_budget(&[hotkey])); + + // One hotkey plus 255 raw basket rows exactly fills the 256-unit envelope. + for raw_netuid in 1..MAX_ROOT_CLAIM_WORK as u16 { + AlphaV2::::insert( + (hotkey, escrow, NetUid::from(raw_netuid)), + share_pool::SafeFloat::from(1_u64), + ); + } + assert!(SubtensorModule::root_claim_fits_declared_budget(&[hotkey])); + + AlphaV2::::insert( + (hotkey, escrow, NetUid::from(MAX_ROOT_CLAIM_WORK as u16)), + share_pool::SafeFloat::from(1_u64), + ); + assert!(!SubtensorModule::root_claim_fits_declared_budget(&[hotkey])); + }); +} + +#[test] +fn test_coldkey_wide_claim_selects_only_root_relevant_hotkeys() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(1001); + let root_hotkey = U256::from(1002); + let subnet_hotkey = U256::from(1003); + let outstanding_hotkey = U256::from(1004); + let stale_hotkey = U256::from(1005); + let subnet = add_dynamic_network(&subnet_hotkey, &coldkey); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &root_hotkey, + &coldkey, + NetUid::ROOT, + 1_u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &subnet_hotkey, + &coldkey, + subnet, + 1_u64.into(), + ); + BasketClaimed::::insert(outstanding_hotkey, coldkey, -1); + StakingHotkeys::::insert( + coldkey, + vec![root_hotkey, subnet_hotkey, outstanding_hotkey, stale_hotkey], + ); + + assert_eq!( + SubtensorModule::root_claim_hotkeys(&coldkey, StakingHotkeys::::get(coldkey)), + vec![root_hotkey, outstanding_hotkey] + ); + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(coldkey), + BTreeSet::new() + )); + }); +} + // ============================================================================= // Beta basket: setting weights (extrinsic validation) // ============================================================================= @@ -3359,11 +3428,10 @@ fn test_root_basket_coldkey_swap_carries_owed_with_zero_stake() { }); } -/// 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. +/// A positive marked entitlement must remain claimable when its proportional alpha slice +/// floors to zero. One atomic alpha unit is sold and payment is capped at the entitlement. #[test] -fn test_root_basket_zero_realized_claim_burns_nothing() { +fn test_root_basket_rounding_zero_take_sells_minimum_unit() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); @@ -3406,31 +3474,35 @@ fn test_root_basket_zero_realized_claim_burns_nothing() { assert!((90..=100).contains(&owed_before), "owed = {owed_before}"); let shares_before = fund_shares(&hotkey); let escrow_before = escrow_alpha(&hotkey, netuid); + let payout_before = SubtensorModule::get_basket_payout_tao(&hotkey, &coldkey); let root_before = root_stake_of(&hotkey, &coldkey); + assert!(payout_before > 0); assert_ok!(SubtensorModule::claim_root_with_hotkey( RuntimeOrigin::signed(coldkey), hotkey )); - // Complete no-op: no shares burned, no watermark advanced, nothing moved. + // Rebasing the claimed payout's new root stake can leave one share from fixed-point + // truncation, matching the invariant used by the other claim-drain tests. + assert!( + SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey) <= 1, + "minimum-unit claim left more than rounding dust" + ); + assert_eq!(fund_shares(&hotkey), shares_before - owed_before); + assert_eq!(escrow_alpha(&hotkey, netuid), escrow_before - 1); assert_eq!( - SubtensorModule::get_basket_owed_shares(&hotkey, &coldkey), - owed_before, - "owed shares must not be burned for a zero payout" + root_stake_of(&hotkey, &coldkey) - root_before, + payout_before, + "the minimum-unit sale must not overpay the marked entitlement" ); - 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 mixed claim — one holding pays, another valuable holding floors to take == 0 — must not -/// settle. Burning all owed shares would hand the unsliced holding to remaining shareholders -/// (99/100 of a 1-alpha position → floor 0; the leftover 1-share holder then owns it whole). +/// A tiny root-cash row whose claimant slice rounds to zero must not block another payable row. +/// Root cash is already denominated in rao, so the indivisible remainder simply stays in escrow. #[test] -fn test_root_basket_mixed_forfeit_claim_burns_nothing() { +fn test_root_basket_rounding_zero_root_row_does_not_block_payable_rows() { new_test_ext(1).execute_with(|| { let owner_coldkey = U256::from(1001); let hotkey = U256::from(1002); @@ -3464,14 +3536,19 @@ fn test_root_basket_mixed_forfeit_claim_burns_nothing() { set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); let escrow = SubtensorModule::get_beta_escrow_account_id(); - // Paying root-slot cash + one atomic unit of expensive curated alpha. + // One rao of root cash rounds to zero for Alice, while the curated alpha row pays. mock_increase_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &escrow, NetUid::ROOT, - 1_000_000u64.into(), + 1u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &escrow, + netuid, + 1_000u64.into(), ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, netuid, 1u64.into()); BasketShares::::insert(hotkey, 100u64); BasketRate::::insert(hotkey, I96F32::from_num(1)); @@ -3479,12 +3556,15 @@ fn test_root_basket_mixed_forfeit_claim_burns_nothing() { assert_eq!(alice_owed, 99, "alice owed = {alice_owed}"); assert_eq!(SubtensorModule::get_basket_owed_shares(&hotkey, &bob), 1); - // Alice's alpha take floors: floor(1 * 99 / 100) = 0; root take is positive. + // Alice's root take floors: floor(1 * 99 / 100) = 0; subnet take is positive. assert_eq!(SubtensorModule::mul_div_u64(1, 99, 100), 0); - assert!(SubtensorModule::mul_div_u64(1_000_000, 99, 100) > 0); + assert!(SubtensorModule::mul_div_u64(1_000, 99, 100) > 0); let shares_before = fund_shares(&hotkey); let alpha_before = escrow_alpha(&hotkey, netuid); + let alice_payout = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + let alice_subnet_payout = + SubtensorModule::get_basket_subnet_payout_tao(&hotkey, &alice, netuid); let alice_root_before = root_stake_of(&hotkey, &alice); assert_ok!(SubtensorModule::claim_root_with_hotkey( @@ -3492,15 +3572,25 @@ fn test_root_basket_mixed_forfeit_claim_burns_nothing() { hotkey )); - // Complete no-op: Alice must not burn shares for a bag that forfeits valuable alpha. + assert_eq!(SubtensorModule::get_basket_owed_shares(&hotkey, &alice), 0); + assert_eq!(fund_shares(&hotkey), shares_before - alice_owed); + assert_eq!(escrow_alpha(&hotkey, netuid), alpha_before - 990); + assert!(escrow_alpha(&hotkey, NetUid::ROOT) >= 1); assert_eq!( - SubtensorModule::get_basket_owed_shares(&hotkey, &alice), - alice_owed + root_stake_of(&hotkey, &alice) - alice_root_before, + alice_subnet_payout ); - assert_eq!(fund_shares(&hotkey), shares_before); - assert_eq!(escrow_alpha(&hotkey, netuid), alpha_before); - assert_eq!(root_stake_of(&hotkey, &alice), alice_root_before); - assert_eq!(BasketClaimed::::get(hotkey, alice), 0); + assert!(alice_subnet_payout > 0 && alice_subnet_payout <= alice_payout); + // Bob can later collect the root remainder together with his remaining alpha share. + let bob_payout = SubtensorModule::get_basket_payout_tao(&hotkey, &bob); + let bob_root_before = root_stake_of(&hotkey, &bob); + assert!(bob_payout > 0); + assert_ok!(SubtensorModule::claim_root_with_hotkey( + RuntimeOrigin::signed(bob), + hotkey + )); + assert_eq!(root_stake_of(&hotkey, &bob) - bob_root_before, bob_payout); + assert_eq!(fund_shares(&hotkey), 0); }); } diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 6805dbe8bd..7af0a5e131 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -7072,11 +7072,12 @@ fn test_migrate_historical_alpha_burns_skips_non_mainnet() { #[test] fn test_storage_bloat_cleanup_is_bounded_and_preserves_nonzero_state() { use crate::migrations::migrate_storage_bloat_v2::{ - StorageBloatCleanupMigration, continue_storage_bloat_cleanup, kickoff_storage_bloat_cleanup, + StorageBloatCleanupMigration, continue_storage_bloat_cleanup, + kickoff_storage_bloat_cleanup, storage_bloat_cleanup_complete, }; new_test_ext(1).execute_with(|| { - const MIGRATION_NAME: &[u8] = b"migrate_storage_bloat_v2"; + const MIGRATION_NAME: &[u8] = b"migrate_storage_bloat_v3"; let netuid = NetUid::from(1); let hot_zero = U256::from(10); let hot_nonzero = U256::from(11); @@ -7109,6 +7110,11 @@ fn test_storage_bloat_cleanup_is_bounded_and_preserves_nonzero_state() { Alpha::::insert((hot_zero, coldkey, netuid), U64F64::from_num(0)); Alpha::::insert((hot_nonzero, coldkey, netuid), U64F64::from_num(7)); + AlphaV2::::insert((hot_zero, coldkey, netuid), share_pool::SafeFloat::zero()); + AlphaV2::::insert( + (hot_nonzero, coldkey, netuid), + share_pool::SafeFloat::from(8_u64), + ); TotalHotkeyShares::::insert(hot_zero, netuid, U64F64::from_num(0)); TotalHotkeyShares::::insert(hot_nonzero, netuid, U64F64::from_num(9)); TotalHotkeyAlpha::::insert(hot_zero, netuid, AlphaBalance::ZERO); @@ -7125,6 +7131,7 @@ fn test_storage_bloat_cleanup_is_bounded_and_preserves_nonzero_state() { let kickoff_weight = kickoff_storage_bloat_cleanup::(); assert!(!kickoff_weight.is_zero()); assert!(StorageBloatCleanupMigration::::exists()); + assert!(!storage_bloat_cleanup_complete::()); let limit = ::DbWeight::get().reads_writes(8, 5); let mut passes = 0; @@ -7136,6 +7143,7 @@ fn test_storage_bloat_cleanup_is_bounded_and_preserves_nonzero_state() { } assert!(passes > 1, "test must exercise resumable progress"); assert!(HasMigrationRun::::get(MIGRATION_NAME)); + assert!(storage_bloat_cleanup_complete::()); for key in dead_keys { assert!(sp_io::storage::get(&key).is_none()); @@ -7145,6 +7153,11 @@ fn test_storage_bloat_cleanup_is_bounded_and_preserves_nonzero_state() { Alpha::::get((hot_nonzero, coldkey, netuid)), U64F64::from_num(7) ); + assert!(!AlphaV2::::contains_key((hot_zero, coldkey, netuid))); + assert_eq!( + AlphaV2::::get((hot_nonzero, coldkey, netuid)), + share_pool::SafeFloat::from(8_u64) + ); assert!(!TotalHotkeyShares::::contains_key(hot_zero, netuid)); assert_eq!( TotalHotkeyShares::::get(hot_nonzero, netuid), @@ -7214,15 +7227,26 @@ fn test_staking_hotkeys_cleanup_is_bounded_and_preserves_live_relationships() { let v2 = U256::from(202); let basket = U256::from(203); let other_stale = U256::from(204); + let zero_v2 = U256::from(205); let netuid = NetUid::from(1); - StakingHotkeys::::insert(coldkey, vec![stale, legacy, v2, basket]); + // The first cleanup already ran on deployed chains. The fresh v2 marker must schedule + // the same bounded implementation again. + HasMigrationRun::::insert(&b"migrate_cleanup_staking_hotkeys"[..], true); + StakingHotkeys::::insert(coldkey, vec![stale, legacy, v2, basket, zero_v2]); StakingHotkeys::::insert(all_stale_coldkey, vec![other_stale]); StakingHotkeys::::insert(empty_coldkey, Vec::::new()); Alpha::::insert((legacy, coldkey, netuid), U64F64::from_num(1)); AlphaV2::::insert((v2, coldkey, netuid), share_pool::SafeFloat::from(1_u64)); + AlphaV2::::insert((zero_v2, coldkey, netuid), share_pool::SafeFloat::zero()); BasketClaimed::::insert(basket, coldkey, -1); + crate::migrations::migrate_storage_bloat_v2::kickoff_storage_bloat_cleanup::(); + crate::migrations::migrate_storage_bloat_v2::continue_storage_bloat_cleanup::( + Weight::MAX, + ); + assert!(!AlphaV2::::contains_key((zero_v2, coldkey, netuid))); + let kickoff_weight = kickoff_staking_hotkeys_cleanup::(); assert!(!kickoff_weight.is_zero()); assert!(StakingHotkeysCleanupMigration::::exists()); diff --git a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs index 9619f8eccd..86d286fd3f 100644 --- a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs +++ b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs @@ -2879,23 +2879,27 @@ fn test_revert_hotkey_swap_with_revert_stake_the_same() { // Let's check individual stakes; they changed because of emissions let old_hotkey_stake_after_revert_ck = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hk1, &coldkey, netuid_1); - assert_eq!( + // Dust canonicalization can redistribute less than one rao while moving a position. + assert_abs_diff_eq!( old_hotkey_stake_after_revert_ck, - new_hotkey_stake_before_revert_ck + new_hotkey_stake_before_revert_ck, + epsilon = 1.into() ); let old_hotkey_stake_after_revert_ck_2 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hk1, &coldkey_2, netuid_1); - assert_eq!( + assert_abs_diff_eq!( old_hotkey_stake_after_revert_ck_2, - new_hotkey_stake_before_revert_ck_2 + new_hotkey_stake_before_revert_ck_2, + epsilon = 1.into() ); let old_hotkey_stake_after_revert_ck_3 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hk1, &coldkey_3, netuid_1); - assert_eq!( + assert_abs_diff_eq!( old_hotkey_stake_after_revert_ck_3, - new_hotkey_stake_before_revert_ck_3 + new_hotkey_stake_before_revert_ck_3, + epsilon = 1.into() ); }); } diff --git a/primitives/share-pool/src/lib.rs b/primitives/share-pool/src/lib.rs index 848ee64448..659eddffd7 100644 --- a/primitives/share-pool/src/lib.rs +++ b/primitives/share-pool/src/lib.rs @@ -438,6 +438,17 @@ where .into() } + fn try_get_value_from_parts( + shared_value: u64, + current_share: &SafeFloat, + denominator: &SafeFloat, + ) -> Option { + let shared_value = SafeFloat::new(shared_value as u128, 0)?; + shared_value + .mul_div(current_share, denominator) + .map(u64::from) + } + pub fn try_get_value(&self, key: &K) -> Result { match self.state_ops.try_get_share(key) { Ok(_) => Ok(self.get_value(key)), @@ -501,8 +512,8 @@ where self.state_ops.set_denominator(update_float.clone()); self.state_ops.set_share(key, update_float); } else { - let new_denominator; - let new_current_share; + let mut new_denominator; + let mut new_current_share; let shares_per_update: SafeFloat = self.get_shares_per_update(update, shared_value, &denominator); @@ -563,6 +574,25 @@ where }; } + // Withdrawing the integer value reported for a position can leave a positive + // fractional share worth less than one rao. If retained, later emissions can make + // that supposedly drained position visible again. Canonicalize such withdrawal + // dust to zero and remove it from the denominator so the remaining pool shares + // continue to sum to the denominator. Never interpret a failed valuation as zero. + let updated_shared_value = shared_value.saturating_sub(update.unsigned_abs()); + if update < 0 + && !new_current_share.is_zero() + && Self::try_get_value_from_parts( + updated_shared_value, + &new_current_share, + &new_denominator, + ) == Some(0) + && let Some(denominator_without_dust) = new_denominator.sub(&new_current_share) + { + new_denominator = denominator_without_dust; + new_current_share = SafeFloat::zero(); + } + self.state_ops.set_denominator(new_denominator); self.state_ops.set_share(key, new_current_share); } @@ -704,6 +734,28 @@ mod tests { assert_eq!(value2, 10); } + #[test] + fn test_full_integer_withdrawal_clears_sub_rao_share_residue() { + let mock_ops = MockSharePoolDataOperations::new(); + let mut pool = SharePool::::new(mock_ops); + + pool.update_value_for_one(&1, 1); + pool.update_value_for_one(&2, 2); + pool.update_value_for_all(1); + + // Key 1 owns 4/3 rao but can only withdraw the displayed integer rao. Without dust + // canonicalization this leaves a positive 1/3-rao share which later revives. + assert_eq!(pool.get_value(&1), 1); + pool.update_value_for_one(&1, -1); + + assert!(pool.state_ops.get_share(&1).is_zero()); + assert_eq!(pool.get_value(&2), 3); + + pool.update_value_for_all(10); + assert_eq!(pool.get_value(&1), 0, "a drained position must not revive"); + assert_eq!(pool.get_value(&2), 13); + } + // cargo test --package share-pool --lib -- tests::test_denom_high_precision --exact --show-output #[test] fn test_denom_high_precision() { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 87cda31f7c..fb73119369 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -235,7 +235,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 453, + spec_version: 454, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/runtime/tests/claim_root_weight.rs b/runtime/tests/claim_root_weight.rs index b65b5b1a85..a644a12d0a 100644 --- a/runtime/tests/claim_root_weight.rs +++ b/runtime/tests/claim_root_weight.rs @@ -2,13 +2,36 @@ use frame_support::dispatch::{DispatchClass, GetDispatchInfo}; use node_subtensor_runtime::{ - BlockWeights, Runtime, RuntimeCall, TxExtension, check_mortality, check_nonce, sudo_wrapper, + BlockWeights, BuildStorage, Runtime, RuntimeCall, RuntimeGenesisConfig, System, TxExtension, + check_mortality, check_nonce, sudo_wrapper, transaction_payment_wrapper::ChargeTransactionPaymentWrapper, }; use sp_runtime::{generic::Era, traits::TransactionExtension}; use sp_std::collections::btree_set::BTreeSet; use subtensor_runtime_common::{AccountId, NetUid, TaoBalance}; +fn new_test_ext() -> sp_io::TestExternalities { + let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig::default() + .build_storage() + .expect("runtime genesis storage builds") + .into(); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +fn expected_root_claim_weight(limit: u32) -> frame_support::weights::Weight { + use pallet_subtensor::weights::WeightInfo; + + pallet_subtensor::weights::SubstrateWeight::::claim_root(limit) + .saturating_add( + pallet_subtensor::weights::SubstrateWeight::::claim_root_scan(limit), + ) + // FRAME folds the runtime's dispatch-extension weight into `call_weight`. + .saturating_add( + pallet_subtensor::weights::SubstrateWeight::::check_coldkey_swap_extension(), + ) +} + fn assert_call_fits_normal_limit(call: RuntimeCall) { let extensions: TxExtension = ( ( @@ -45,16 +68,28 @@ fn assert_call_fits_normal_limit(call: RuntimeCall) { #[test] fn claim_root_with_extensions_fits_normal_extrinsic_limit() { - let call = RuntimeCall::SubtensorModule(pallet_subtensor::Call::claim_root { - subnets: BTreeSet::from([NetUid::ROOT]), + new_test_ext().execute_with(|| { + let call = RuntimeCall::SubtensorModule(pallet_subtensor::Call::claim_root { + subnets: BTreeSet::from([NetUid::ROOT]), + }); + assert_eq!( + call.get_dispatch_info().call_weight, + expected_root_claim_weight(pallet_subtensor::MAX_ROOT_CLAIM_WORK) + ); + assert_call_fits_normal_limit(call); }); - assert_call_fits_normal_limit(call); } #[test] fn claim_root_with_hotkey_with_extensions_fits_normal_extrinsic_limit() { - let hotkey = AccountId::new([1u8; 32]); - let call = - RuntimeCall::SubtensorModule(pallet_subtensor::Call::claim_root_with_hotkey { hotkey }); - assert_call_fits_normal_limit(call); + new_test_ext().execute_with(|| { + let hotkey = AccountId::new([1u8; 32]); + let call = + RuntimeCall::SubtensorModule(pallet_subtensor::Call::claim_root_with_hotkey { hotkey }); + assert_eq!( + call.get_dispatch_info().call_weight, + expected_root_claim_weight(pallet_subtensor::MAX_ROOT_CLAIM_WORK) + ); + assert_call_fits_normal_limit(call); + }); } diff --git a/sdk/python/bittensor/error_descriptions/subtensor.py b/sdk/python/bittensor/error_descriptions/subtensor.py index e7d24c7c3a..3409f24eae 100644 --- a/sdk/python/bittensor/error_descriptions/subtensor.py +++ b/sdk/python/bittensor/error_descriptions/subtensor.py @@ -86,11 +86,12 @@ "a hotkey the beneficiary coldkey actually owns." ), "RootClaimTooHeavy": ( - "A root claim would walk more work than the fixed pre-dispatch weight envelope can " - "admit. For coldkey-wide `claim_root`, hotkeys times existing networks or total basket " - "rows can exceed `MAX_ROOT_CLAIM_WORK`; a single validator basket with too many rows " - "can also make `claim_root_with_hotkey` fail. Split a coldkey-wide claim by validator " - "where that fits, and investigate or consolidate an individually oversized basket." + "A root claim would process more than the fixed 256-unit admission envelope. Only " + "root-relevant validator hotkeys and their stored basket rows count as claim work; " + "classifying the staking-hotkey relationship vector is separately capped at 256. " + "Unrelated subnet stakes are not multiplied by the total network count. Split a " + "coldkey-wide claim by validator where that fits, and investigate or consolidate an " + "individually oversized basket." ), "BetaBasketSeedInProgress": ( "The `migrate_seed_beta_basket_v2` seed has not completed (it normally finishes " diff --git a/sdk/python/bittensor/intents/_root_claim_fee.py b/sdk/python/bittensor/intents/_root_claim_fee.py index 1f81128b56..1091b5652f 100644 --- a/sdk/python/bittensor/intents/_root_claim_fee.py +++ b/sdk/python/bittensor/intents/_root_claim_fee.py @@ -1,9 +1,8 @@ """Claim-fee preview for ``claim_root`` / ``claim_root_with_hotkey``. -Both runtime calls reserve ``MAX_ROOT_CLAIM_WORK`` (256) weight units at -inclusion, then refund down to the work actually done. That reserve is what -people see leave their free balance, and it is larger than the fee that -finally settles. That gap is the usual claim-fee surprise. +Both runtime calls reserve a fixed declared-work envelope at inclusion, then +refund down to the work actually done. The reserve is what people see leave +their free balance, and it is larger than the fee that finally settles. This module estimates both numbers, compares the spent fee to accrued yield, and tells the caller when a claim loses money or cannot even be included. @@ -16,7 +15,7 @@ from typing import Any, Awaitable, Callable, Optional from .._generated import storage as st -from .._generated.runtime_apis import BetaBasketRuntimeApi +from .._generated.runtime_apis import BetaBasketRuntimeApi, StakeInfoRuntimeApi from ..balance import Balance from ..sp_core import ss58_decode @@ -26,11 +25,17 @@ _REDEEM_REF_TIME = 70 # One full ``claim_root`` weight unit under LinearWeightToFee (~τ0.0004475). -# Both claim paths reserve ``MAX_ROOT_CLAIM_WORK`` of these, plus any -# non-weight base/length fee returned by ``payment_info``. +# Both claim paths reserve 256 redeem and scan units, +# plus any non-weight base/length fee returned by ``payment_info``. _APPROX_REDEEM_FEE_RAO = 447_500 +_APPROX_SCAN_FEE_RAO = _APPROX_REDEEM_FEE_RAO * _SCAN_REF_TIME // _REDEEM_REF_TIME _MAX_ROOT_CLAIM_WORK = 256 + +def _approx_declared_fee_rao(limit: int) -> int: + return (_APPROX_REDEEM_FEE_RAO + _APPROX_SCAN_FEE_RAO) * limit + + # Default ``RootClaimableThreshold`` (500_000 rao) when storage is empty. _DEFAULT_THRESHOLD_RAO = 500_000 @@ -51,22 +56,31 @@ def _i96f32_rao(value: Any) -> int: return int(value or 0) >> 32 -def _admission_blocks(hotkeys: int, networks: int, holdings: int) -> list[str]: - work = hotkeys * networks - if work <= _MAX_ROOT_CLAIM_WORK and holdings <= _MAX_ROOT_CLAIM_WORK: +def _admission_blocks( + hotkeys: int, + holdings: int, + limit: int, + selection_scans: int, +) -> list[str]: + work = hotkeys + holdings + if work <= limit and selection_scans <= limit: return [] - reasons: list[str] = [] - if work > _MAX_ROOT_CLAIM_WORK: - reasons.append(f"{hotkeys} hotkeys × {networks} networks = {work}") - if holdings > _MAX_ROOT_CLAIM_WORK: - reasons.append(f"{holdings} basket holdings") remediation = ( "claim one validator at a time with claim_root_with_hotkey" if hotkeys > 1 else "the claim cannot be admitted until this basket's work is reduced" ) + reasons = [] + if work > limit: + hotkey_label = "hotkey" if hotkeys == 1 else "hotkeys" + holding_label = "holding" if holdings == 1 else "holdings" + reasons.append( + f"{hotkeys} root {hotkey_label} + {holdings} basket {holding_label} = {work}" + ) + if selection_scans > limit: + reasons.append(f"{selection_scans} staking-hotkey relationships to classify") return [ - "root claim exceeds the 256-unit admission limit (" + f"root claim exceeds the {limit:,}-unit admission limit (" + "; ".join(reasons) + f"); {remediation}" ] @@ -79,6 +93,8 @@ class RootClaimAdmission: hotkeys: tuple[str, ...] holding_counts: tuple[int, ...] networks: int + limit: int + selection_scans: int @property def holdings(self) -> int: @@ -86,13 +102,15 @@ def holdings(self) -> int: @property def too_heavy(self) -> bool: - return ( - len(self.hotkeys) * self.networks > _MAX_ROOT_CLAIM_WORK - or self.holdings > _MAX_ROOT_CLAIM_WORK - ) + return len(self.hotkeys) + self.holdings > self.limit or self.selection_scans > self.limit def blocks(self) -> list[str]: - return _admission_blocks(len(self.hotkeys), self.networks, self.holdings) + return _admission_blocks( + len(self.hotkeys), + self.holdings, + self.limit, + self.selection_scans, + ) @dataclass(frozen=True) @@ -102,6 +120,7 @@ class RootClaimWork: hotkeys: int redeem_holdings: int scan_holdings: int + selection_scans: int = 0 @dataclass(frozen=True) @@ -133,6 +152,8 @@ class RootClaimFeeQuote: eligible_hotkeys: int below_threshold_hotkeys: int redeemable: Balance + admission_limit: int + selection_scans: int @property def refund(self) -> Balance: @@ -153,8 +174,8 @@ def below_threshold(self) -> bool: @property def too_heavy(self) -> bool: return ( - self.hotkeys * self.networks > _MAX_ROOT_CLAIM_WORK - or self.holdings > _MAX_ROOT_CLAIM_WORK + self.hotkeys + self.holdings > self.admission_limit + or self.selection_scans > self.admission_limit ) def facts(self) -> list[tuple[str, str]]: @@ -219,7 +240,14 @@ def warnings(self) -> list[str]: def blocks(self) -> list[str]: out: list[str] = [] if self.too_heavy: - out.extend(_admission_blocks(self.hotkeys, self.networks, self.holdings)) + out.extend( + _admission_blocks( + self.hotkeys, + self.holdings, + self.admission_limit, + self.selection_scans, + ) + ) if self.reserve_shortfall: out.append(f"free TAO ({self.free}) is below the reserved claim fee ({self.reserved})") return out @@ -231,7 +259,7 @@ async def root_claim_admission( *, hotkeys: Optional[list[str]], ) -> RootClaimAdmission: - """Read only the state used by the runtime's 256-unit admission guard. + """Read only the state used by the runtime's fixed admission guard. Unlike the fee/yield quote, this check is not best-effort: callers use a failed read as a hard stop because signing an unverifiable claim can burn @@ -239,11 +267,38 @@ async def root_claim_admission( """ if hotkeys is None: raw_keys = await substrate.query(*st.SubtensorModule.StakingHotkeys, [claimant_address]) - selected = tuple(str(key) for key in (raw_keys or [])) + raw_keys = tuple(str(key) for key in (raw_keys or [])) + stake_rows = await substrate.runtime_call( + *StakeInfoRuntimeApi.get_stake_info_for_coldkey, + [claimant_address], + ) + if stake_rows is None: + raise RuntimeError("coldkey stake positions are unavailable") + root_hotkeys = { + str(row["hotkey"]) + for row in stake_rows + if int(row["netuid"]) == 0 and int(row["stake"]) > 0 + } + watermarks = await asyncio.gather( + *( + substrate.query( + *st.SubtensorModule.BasketClaimed, + [hotkey, claimant_address], + ) + for hotkey in raw_keys + ) + ) + selected = tuple( + hotkey + for hotkey, watermark in zip(raw_keys, watermarks) + if hotkey in root_hotkeys or int(watermark or 0) < 0 + ) + selection_scans = len(raw_keys) else: if len(hotkeys) != 1: raise ValueError("per-validator admission expects exactly one hotkey") selected = tuple(hotkeys) + selection_scans = 0 semaphore = asyncio.Semaphore(16) @@ -264,6 +319,8 @@ async def holding_count(hotkey: str) -> int: hotkeys=selected, holding_counts=tuple(holding_counts), networks=networks, + limit=_MAX_ROOT_CLAIM_WORK, + selection_scans=selection_scans, ) @@ -373,7 +430,9 @@ async def _quote( hotkeys=max(len(selected_hotkeys), 1), redeem_holdings=redeem_holdings, scan_holdings=scan_holdings, + selection_scans=admission.selection_scans, ), + admission.limit, ) return RootClaimFeeQuote( @@ -388,6 +447,8 @@ async def _quote( eligible_hotkeys=sum(eligible), below_threshold_hotkeys=sum(below_threshold), redeemable=Balance.from_rao(redeemable_rao), + admission_limit=admission.limit, + selection_scans=admission.selection_scans, ) @@ -433,7 +494,7 @@ async def _reserved_fee_with_status( call = await compose() return await substrate.estimate_fee(call, _FeeView(signer_address)), True except Exception: - return Balance.from_rao(_APPROX_REDEEM_FEE_RAO * _MAX_ROOT_CLAIM_WORK), False + return Balance.from_rao(_approx_declared_fee_rao(_MAX_ROOT_CLAIM_WORK)), False async def root_claim_reserve( @@ -461,26 +522,23 @@ async def root_claim_reserve( def _spent_fee( reserved: Balance, work: RootClaimWork, + declared_work: int = _MAX_ROOT_CLAIM_WORK, ) -> Balance: """Refund unused declared units; keep non-weight base/length fees intact. - Runtime active units are ``max(hotkey_count, realized + swept, 1)``. The - quote floors by the selected hotkey count so empty-basket validators still - cost a full unit. ``estimate_fee`` prices the 256-unit declaration plus - extrinsic base/length; only the weight slice scales. + Runtime active units are ``max(selected hotkeys, relationships classified, + realized + swept, 1)``. Classifying a relationship reads its root share-pool + state, so the quote prices it conservatively as a full hotkey unit. + ``estimate_fee`` prices the fixed declaration plus extrinsic base/length; + only the weight slice scales. """ if reserved.rao <= 0: return reserved - declared_weight = _APPROX_REDEEM_FEE_RAO * _MAX_ROOT_CLAIM_WORK + declared_weight = _approx_declared_fee_rao(declared_work) weight_part = min(reserved.rao, declared_weight) base_part = max(0, reserved.rao - declared_weight) - active = max(work.redeem_holdings, work.hotkeys, 1) - active_weight = weight_part * active // _MAX_ROOT_CLAIM_WORK - scan_weight = ( - weight_part - * max(work.scan_holdings, 0) - * _SCAN_REF_TIME - // (_MAX_ROOT_CLAIM_WORK * _REDEEM_REF_TIME) - ) + active = max(work.redeem_holdings, work.hotkeys, work.selection_scans, 1) + active_weight = weight_part * active * _APPROX_REDEEM_FEE_RAO // declared_weight + scan_weight = weight_part * max(work.scan_holdings, 0) * _APPROX_SCAN_FEE_RAO // declared_weight spent_weight = active_weight + scan_weight return Balance.from_rao(min(reserved.rao, base_part + max(spent_weight, 0))) diff --git a/sdk/python/bittensor/intents/registration.py b/sdk/python/bittensor/intents/registration.py index 04f7c39eaf..8143c0e3a5 100644 --- a/sdk/python/bittensor/intents/registration.py +++ b/sdk/python/bittensor/intents/registration.py @@ -277,7 +277,7 @@ async def _claim_preflight( effects=[self.summary()], warnings=[], blocks=[ - "could not verify the root claim's 256-unit admission budget; " + "could not verify the root claim's fixed admission budget; " f"refusing to risk the unreduced declared fee ({error})" ], ) @@ -422,9 +422,10 @@ class ClaimRootWithHotkey(_RootClaimIntent): per-holding claim fee shrinks over time; curated positions are left to compound. The transaction fee is charged by work actually done: holdings redeemed pay full weight, holdings merely scanned pay a small - per-row cost. The chain reserves a fixed 256-unit declared-work envelope at - inclusion, independent of the current network count, and refunds the unused - part after. + per-row cost. The chain reserves a fixed 256-unit declared-work envelope, + counts only root-relevant hotkeys and their basket rows for admission, and + separately caps classification of the staking-hotkey vector at 256. It + refunds the unused part after. ``plan`` and ``btcli root claim --dry-run`` show reserved versus spent, warn when the spent fee exceeds accrued yield, and refuse when free TAO cannot cover the reserve. diff --git a/sdk/python/tests/unit/test_root_claim_fee.py b/sdk/python/tests/unit/test_root_claim_fee.py index 809ad8f2a2..c3dd833ee5 100644 --- a/sdk/python/tests/unit/test_root_claim_fee.py +++ b/sdk/python/tests/unit/test_root_claim_fee.py @@ -1,4 +1,4 @@ -"""Reserved/spent root-claim fees follow the 256-unit runtime envelope.""" +"""Reserved/spent root-claim fees follow the fixed runtime claim envelope.""" from __future__ import annotations @@ -19,11 +19,20 @@ async def _boom(): raise RuntimeError("no payment_info") reserved = await fees._reserved_fee(object(), "5F3sa2TJAW", _boom) - assert reserved.rao == fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK + assert reserved.rao == fees._approx_declared_fee_rao(fees._MAX_ROOT_CLAIM_WORK) -def test_spent_scales_against_256_not_network_count(): - reserved = Balance.from_rao(fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK) +@pytest.mark.asyncio +async def test_reserved_fallback_uses_fixed_envelope(): + async def _boom(): + raise RuntimeError("no payment_info") + + reserved = await fees._reserved_fee(FakeSubstrate(), "5F3sa2TJAW", _boom) + assert reserved.rao == fees._approx_declared_fee_rao(fees._MAX_ROOT_CLAIM_WORK) + + +def test_spent_scales_against_chain_declaration_not_network_count(): + reserved = Balance.from_rao(fees._approx_declared_fee_rao(fees._MAX_ROOT_CLAIM_WORK)) spent = fees._spent_fee( reserved, fees.RootClaimWork(hotkeys=1, redeem_holdings=32, scan_holdings=0), @@ -33,7 +42,7 @@ def test_spent_scales_against_256_not_network_count(): def test_spent_keeps_non_weight_base_fee(): base = 12_345 - reserved = Balance.from_rao(base + fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK) + reserved = Balance.from_rao(base + fees._approx_declared_fee_rao(fees._MAX_ROOT_CLAIM_WORK)) spent = fees._spent_fee( reserved, fees.RootClaimWork(hotkeys=1, redeem_holdings=16, scan_holdings=0), @@ -42,19 +51,18 @@ def test_spent_keeps_non_weight_base_fee(): def test_scan_only_uses_scan_ref_time(): - reserved = Balance.from_rao(fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK) + reserved = Balance.from_rao(fees._approx_declared_fee_rao(fees._MAX_ROOT_CLAIM_WORK)) spent = fees._spent_fee( reserved, fees.RootClaimWork(hotkeys=1, redeem_holdings=0, scan_holdings=32), ) - denom = fees._MAX_ROOT_CLAIM_WORK * fees._REDEEM_REF_TIME - scan = reserved.rao * 32 * fees._SCAN_REF_TIME // denom - walk = reserved.rao * 1 // fees._MAX_ROOT_CLAIM_WORK + scan = fees._APPROX_SCAN_FEE_RAO * 32 + walk = fees._APPROX_REDEEM_FEE_RAO assert spent.rao == walk + scan def test_coldkey_wide_empty_baskets_floor_to_hotkey_count(): - reserved = Balance.from_rao(fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK) + reserved = Balance.from_rao(fees._approx_declared_fee_rao(fees._MAX_ROOT_CLAIM_WORK)) spent = fees._spent_fee( reserved, fees.RootClaimWork(hotkeys=100, redeem_holdings=0, scan_holdings=0), @@ -88,6 +96,11 @@ def _seed_claim_quote( "get_root_basket_positions", [(hotkey, 1, payout) for hotkey, payout in payouts.items() if payout], ) + substrate.seed_runtime( + "StakeInfoRuntimeApi", + "get_stake_info_for_coldkey", + [{"hotkey": hotkey, "coldkey": ALICE, "netuid": 0, "stake": 1} for hotkey in hotkeys], + ) substrate.seed_runtime( "BetaBasketRuntimeApi", "get_basket_payout", @@ -171,7 +184,12 @@ async def test_coldkey_quote_does_not_scan_validator_without_owed_shares(): assert quote.below_threshold_hotkeys == 1 assert quote.spent == fees._spent_fee( quote.reserved, - fees.RootClaimWork(hotkeys=2, redeem_holdings=0, scan_holdings=2), + fees.RootClaimWork( + hotkeys=2, + redeem_holdings=0, + scan_holdings=2, + selection_scans=2, + ), ) @@ -179,8 +197,8 @@ async def test_coldkey_quote_does_not_scan_validator_without_owed_shares(): @pytest.mark.parametrize( ("hotkeys", "holdings", "networks", "reason"), [ - ([f"hotkey-{i}" for i in range(17)], 0, 16, "17 hotkeys × 16 networks"), - ([ALICE_HOT], 257, 1, "257 basket holdings"), + ([f"hotkey-{i}" for i in range(257)], 0, 16, "257 root hotkeys + 0 basket holdings"), + ([ALICE_HOT], 256, 1, "1 root hotkey + 256 basket holdings"), ], ) async def test_root_claim_too_heavy_is_a_hard_stop(hotkeys, holdings, networks, reason): @@ -249,7 +267,7 @@ async def test_shielded_claim_reserves_free_tao_for_inner_and_carrier(): @pytest.mark.parametrize("payout_failure", [None, RuntimeError("payout unavailable")]) async def test_admission_hard_stop_survives_unavailable_payout_preview(payout_failure): substrate = FakeSubstrate() - hotkeys = [f"hotkey-{i}" for i in range(17)] + hotkeys = [f"hotkey-{i}" for i in range(257)] _seed_claim_quote( substrate, hotkeys=hotkeys, @@ -269,7 +287,7 @@ def fail_payout(_params): client = Client("local", substrate=substrate) plan = await client.plan(ClaimRoot(), dev_wallet()) - assert any("17 hotkeys × 16 networks" in block for block in plan.violations) + assert any("257 root hotkeys + 0 basket holdings" in block for block in plan.violations) with pytest.raises(PolicyError, match="256-unit admission limit"): await client.submit_shielded(ClaimRoot(), dev_wallet()) @@ -334,8 +352,8 @@ async def test_carrier_hard_stop_survives_unavailable_payout_preview(): ("hotkey_count", "holdings", "networks", "expected_ok"), [ (16, 0, 16, True), - (1, 256, 1, True), - (1, 257, 1, False), + (1, 255, 1, True), + (1, 256, 1, False), ], ) async def test_admission_limit_is_inclusive_at_256(hotkey_count, holdings, networks, expected_ok): @@ -356,7 +374,48 @@ async def test_admission_limit_is_inclusive_at_256(hotkey_count, holdings, netwo @pytest.mark.asyncio -async def test_inactive_network_rows_do_not_count_toward_admission(): +async def test_coldkey_wide_admission_ignores_non_root_hotkeys(): + substrate = FakeSubstrate() + _seed_claim_quote( + substrate, + hotkeys=[ALICE_HOT, BOB_HOT], + payouts={ALICE_HOT: 1_000_000}, + holdings={ALICE_HOT: 1, BOB_HOT: 256}, + networks=500, + ) + substrate.seed_runtime( + "StakeInfoRuntimeApi", + "get_stake_info_for_coldkey", + [ + {"hotkey": ALICE_HOT, "coldkey": ALICE, "netuid": 0, "stake": 1}, + {"hotkey": BOB_HOT, "coldkey": ALICE, "netuid": 7, "stake": 1}, + ], + ) + + plan = await Client("local", substrate=substrate).plan(ClaimRoot(), dev_wallet()) + + assert not any("admission limit" in block for block in plan.violations) + + +@pytest.mark.asyncio +async def test_coldkey_wide_admission_keeps_unstaked_basket_claimant(): + substrate = FakeSubstrate() + _seed_claim_quote( + substrate, + hotkeys=[ALICE_HOT], + payouts={ALICE_HOT: 1_000_000}, + holdings={ALICE_HOT: 1}, + ) + substrate.seed_runtime("StakeInfoRuntimeApi", "get_stake_info_for_coldkey", []) + substrate.seed("SubtensorModule", "BasketClaimed", [ALICE_HOT, ALICE], -1) + + admission = await fees.root_claim_admission(substrate, ALICE, hotkeys=None) + + assert admission.hotkeys == (ALICE_HOT,) + + +@pytest.mark.asyncio +async def test_network_count_does_not_affect_admission(): substrate = FakeSubstrate() hotkeys = [f"hotkey-{i}" for i in range(17)] _seed_claim_quote( @@ -389,6 +448,11 @@ async def test_proxy_claim_reads_dispatch_state_checks_delegate_and_prices_wrapp substrate.seed_runtime( "BetaBasketRuntimeApi", "get_root_basket_positions", [(BOB_HOT, 1, 1_000_000)] ) + substrate.seed_runtime( + "StakeInfoRuntimeApi", + "get_stake_info_for_coldkey", + [{"hotkey": BOB_HOT, "coldkey": BOB, "netuid": 0, "stake": 1}], + ) substrate.seed_runtime( "BetaBasketRuntimeApi", "get_basket_payout",