Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/guides/root-reborn.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/guides/staking.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions pallets/subtensor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 11 additions & 8 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<T as crate::pallet::Config>::WeightInfo::claim_root(Pallet::<T>::root_claim_declared_work())
Pallet::<T>::root_claim_declared_weight()
)]
pub fn claim_root(
origin: OriginFor<T>,
Expand All @@ -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::<T>::get(&coldkey);
let staking_hotkeys = StakingHotkeys::<T>::get(&coldkey);
let selection_scanned = u32::try_from(staking_hotkeys.len()).unwrap_or(u32::MAX);
ensure!(
selection_scanned <= Self::root_claim_declared_work(),
Error::<T>::RootClaimTooHeavy
);
let hotkeys = Self::root_claim_hotkeys(&coldkey, staking_hotkeys);
ensure!(
Self::root_claim_fits_declared_budget(&hotkeys),
Error::<T>::RootClaimTooHeavy
Expand All @@ -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())
}

Expand All @@ -2008,7 +2011,7 @@ mod dispatches {
/// * `RootClaimed`: On successfully claiming the root emissions for this coldkey+hotkey.
#[pallet::call_index(148)]
#[pallet::weight(
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK)
Pallet::<T>::root_claim_declared_weight()
)]
pub fn claim_root_with_hotkey(
origin: OriginFor<T>,
Expand All @@ -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())
}

Expand Down
4 changes: 2 additions & 2 deletions pallets/subtensor/src/macros/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 10 additions & 9 deletions pallets/subtensor/src/macros/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>::exists()
weight.saturating_accrue(storage_bloat_completion_read);
migrations::migrate_storage_bloat_v2::storage_bloat_cleanup_complete::<T>()
} 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -48,11 +50,10 @@ fn row_load_weight<T: Config>() -> 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<T: Config>(hotkey: &T::AccountId, coldkey: &T::AccountId) -> bool {
AlphaV2::<T>::iter_prefix((hotkey, coldkey))
.next()
Expand Down
18 changes: 17 additions & 1 deletion pallets/subtensor/src/migrations/migrate_storage_bloat_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -109,6 +117,14 @@ pub struct StorageBloatCleanupProgress {
pub type StorageBloatCleanupMigration<T: Config> =
StorageValue<Pallet<T>, 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<T: Config>() -> bool {
HasMigrationRun::<T>::get(MIGRATION_NAME)
}

fn storage_prefix(pallet: &str, item: &str) -> Vec<u8> {
[twox_128(pallet.as_bytes()), twox_128(item.as_bytes())].concat()
}
Expand Down
119 changes: 75 additions & 44 deletions pallets/subtensor/src/staking/claim_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -636,21 +636,6 @@ impl<T: Config> Pallet<T> {
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
Expand All @@ -666,8 +651,25 @@ impl<T: Config> Pallet<T> {
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;
}
Expand Down Expand Up @@ -729,8 +731,6 @@ impl<T: Config> Pallet<T> {
// 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.
Expand Down Expand Up @@ -895,51 +895,82 @@ impl<T: Config> Pallet<T> {
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();
<T as crate::pallet::Config>::WeightInfo::claim_root(limit).saturating_add(
<T as crate::pallet::Config>::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<T::AccountId>,
) -> Vec<T::AccountId> {
staking_hotkeys
.into_iter()
.filter(|hotkey| {
!Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT)
.is_zero()
|| BasketClaimed::<T>::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::<T>::iter_prefix((hotkey, &escrow)) {
work = work.saturating_add(1);
if work > budget {
return false;
}
}
for _ in AlphaV2::<T>::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)
Comment on lines 969 to +973

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Post-dispatch weight drops an additive selection workload

selection_scanned is work performed before redemption, but folding it into active with max charges for either relationship classification or active basket processing—not both. For example, a coldkey can have 256 staking relationships while its selected root hotkey also processes many basket rows; the call then reports only claim_root(256) (plus non-realized row scans), despite performing both workloads. Because this value is returned as actual_weight, the declared claim_root(256) + claim_root_scan(256) reservation is refunded and repeated calls can make a block execute materially more work than its recorded weight. Account for selection scans additively using a benchmarked component while retaining the declared envelope.

Comment on lines 969 to +973

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Post-dispatch weight still drops additive selection work

Every StakingHotkeys relationship is classified before the selected hotkeys and basket rows are processed. Taking the maximum of selection_scanned and claim work charges only the larger workload, although both execute. An attacker can therefore pack blocks using understated post-dispatch weight. Account for classification additively, consistently with the declared claim_root + claim_root_scan envelope.

Suggested change
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)
let active = hotkey_count
.max(outcome.realized.saturating_add(outcome.swept))
.max(1);
let scanned = outcome
.rows
.saturating_sub(outcome.realized)
.saturating_add(selection_scanned);
<T as crate::pallet::Config>::WeightInfo::claim_root(active).saturating_add(
<T as crate::pallet::Config>::WeightInfo::claim_root_scan(scanned),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Post-dispatch weight still drops additive selection work

selection_scanned is combined with claim work using max, although classification happens before and in addition to basket processing. A call can classify up to 256 relationships and then perform up to 256 admitted claim-work units, yet its post-dispatch weight reports only the larger workload. Charge classification additively (using its benchmarked cost) before adding basket scan/redeem weight.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Post-dispatch weight still drops additive selection work

selection_scanned represents candidate-classification work performed before the selected hotkeys are claimed. Taking the maximum charges for either classification or claim processing, not both, so a call with many candidates and substantial claim work receives an excessive refund. Account for classification additively—consistent with the declared claim_root(limit) + claim_root_scan(limit) envelope—rather than folding it into active.

Comment on lines 969 to +973

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Post-dispatch weight still drops additive selection work

The classification scan and subsequent claim processing execute sequentially, but max(selection_scanned) charges only the larger workload. A claim with many raw StakingHotkeys entries and substantial selected-hotkey/basket work therefore refunds weight that was actually consumed, permitting blocks to execute beyond their accounted limit. Charge selection work additively and ensure the declared envelope covers the resulting worst case.

Comment on lines 969 to +973

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Post-dispatch weight still drops additive selection work

selection_scanned measures the classification pass performed before do_root_claim, so its cost is additive to selected-hotkey and redemption work. Combining it with max refunds one of two sequential workloads, allowing an extrinsic to report less weight than it consumed. Mirror the additive declaration by adding the measured classification weight separately.

.max(1);
let scanned = outcome.rows.saturating_sub(outcome.realized);
<T as crate::pallet::Config>::WeightInfo::claim_root(active).saturating_add(
Expand Down
Loading
Loading