Skip to content
Closed
94 changes: 92 additions & 2 deletions pallets/subtensor/rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -118,6 +118,31 @@ pub trait SubtensorCustomApi<BlockHash> {
netuid: NetUid,
at: Option<BlockHash>,
) -> RpcResult<Vec<u8>>;

/// 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<BlockHash>,
) -> RpcResult<TaoBalance>;
/// A validator's beta basket net asset value, in TAO.
#[method(name = "betaBasket_getValidatorNav")]
fn get_validator_basket_nav(
&self,
hotkey: AccountId32,
at: Option<BlockHash>,
) -> RpcResult<TaoBalance>;
/// 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<BlockHash>,
) -> RpcResult<Vec<u8>>;
/// Network-wide total beta basket NAV across all validators, in TAO.
#[method(name = "betaBasket_getTotalNav")]
fn get_root_basket_total_nav(&self, at: Option<BlockHash>) -> RpcResult<TaoBalance>;
}

pub struct SubtensorCustom<C, P> {
Expand Down Expand Up @@ -167,6 +192,7 @@ where
C::Api: SubnetInfoRuntimeApi<Block>,
C::Api: StakeInfoRuntimeApi<Block>,
C::Api: SubnetRegistrationRuntimeApi<Block>,
C::Api: BetaBasketRuntimeApi<Block>,
{
fn get_delegates(&self, at: Option<<Block as BlockT>::Hash>) -> RpcResult<Vec<u8>> {
let api = self.client.runtime_api();
Expand Down Expand Up @@ -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<<Block as BlockT>::Hash>,
) -> RpcResult<TaoBalance> {
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<<Block as BlockT>::Hash>,
) -> RpcResult<TaoBalance> {
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<<Block as BlockT>::Hash>,
) -> RpcResult<Vec<u8>> {
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<<Block as BlockT>::Hash>,
) -> RpcResult<TaoBalance> {
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())
}
}
}
}
11 changes: 11 additions & 0 deletions pallets/subtensor/runtime-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,15 @@ sp_api::decl_runtime_apis! {
fn get_proxy_types() -> Vec<ProxyTypeInfo>;
fn get_proxy_filter(proxy_type: Option<u8>) -> Vec<ProxyFilterInfo>;
}

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;
}
}
4 changes: 1 addition & 3 deletions pallets/subtensor/src/coinbase/block_step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ impl<T: Config + pallet_drand::Config> Pallet<T> {
/// 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 = <frame_system::Pallet<T>>::parent_hash();

// --- 1. Update registration burn prices.
Self::update_registration_prices_for_networks();
Expand All @@ -25,8 +24,7 @@ impl<T: Config + pallet_drand::Config> Pallet<T> {
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();
Expand Down
10 changes: 5 additions & 5 deletions pallets/subtensor/src/coinbase/run_coinbase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -705,11 +705,11 @@ impl<T: Config> Pallet<T> {
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::<T>::mutate(netuid, &hotkey, |divs| {
Expand Down
30 changes: 30 additions & 0 deletions pallets/subtensor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
T::AccountId,
Identity,
NetUid,
AlphaBalance,
ValueQuery,
DefaultZeroAlpha<T>,
>;

/// --- 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<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, Vec<(u16, u16)>, ValueQuery>;
#[pallet::storage] // -- MAP ( cold ) --> root_claim_type enum
pub type RootClaimType<T: Config> = StorageMap<
_,
Expand Down
20 changes: 20 additions & 0 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>): destination subnet netuids.
/// * `weights` (Vec<u16>): per-subnet weights (normalized on use).
/// * `version_key` (u64): the network version key.
#[pallet::call_index(139)]
#[pallet::weight((<T as crate::pallet::Config>::WeightInfo::set_weights(), DispatchClass::Normal, Pays::No))]
pub fn set_root_weights(
origin: OriginFor<T>,
dests: Vec<u16>,
weights: Vec<u16>,
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
Expand Down
2 changes: 2 additions & 0 deletions pallets/subtensor/src/macros/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion pallets/subtensor/src/macros/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>())
// 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::<T>());
.saturating_add(migrations::migrate_fix_subnet_hotkey_lock_swaps::migrate_fix_subnet_hotkey_lock_swaps::<T>())
// Seed the beta-basket escrow model from legacy RootClaimable state.
.saturating_add(migrations::migrate_seed_beta_basket::migrate_seed_beta_basket::<T>());
weight
}

Expand Down
114 changes: 114 additions & 0 deletions pallets/subtensor/src/migrations/migrate_seed_beta_basket.rs
Original file line number Diff line number Diff line change
@@ -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<T: Config>() -> Weight {
let migration_name = b"migrate_seed_beta_basket".to_vec();
let mut weight = T::DbWeight::get().reads(1);

if HasMigrationRun::<T>::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::<T>::get_beta_escrow_account_id();
weight.saturating_accrue(T::DbWeight::get().reads(1));

let hotkeys: Vec<T::AccountId> = RootClaimable::<T>::iter_keys().collect();

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] One-shot migration scans unbounded root-claim state

This runtime-upgrade migration collects every RootClaimable hotkey and then, for each claimable slot, scans RootClaimed::iter_prefix. Returning an accumulated Weight does not bound execution; the upgrade block still performs all reads/writes in one shot. On a large state this can exceed the block budget or timeout during runtime upgrade. Convert this to a versioned multi-block migration, or gate deployment on a measured state bound that proves the full scan fits comfortably in one block.

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] One-shot migration scans unbounded root-claim state

This migration runs inside on_runtime_upgrade, but it materializes every RootClaimable hotkey and then scans RootClaimed for every (netuid, hotkey) slot. That work is proportional to live chain state and can exceed the upgrade block budget, which risks an overweight runtime upgrade or a chain-stalling migration. The note acknowledges the risk, but the PR still wires it into the one-shot upgrade path. Convert this to a bounded/multi-block migration or otherwise cap the per-block work before enabling it.

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] One-shot migration scans unbounded root-claim state

This runtime-upgrade migration collects every RootClaimable hotkey and then scans RootClaimed for each (netuid, hotkey) slot. The returned Weight is computed while doing the work, but nothing bounds the amount of state touched before the upgrade block must finish. On a large chain state this can exceed block limits and stall the runtime upgrade. Convert this to a bounded multi-block migration or otherwise cap/chunk the scanned keys before enabling it on a live chain.

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] One-shot migration scans unbounded root-claim state

This runtime upgrade collects every RootClaimable hotkey, then for each slot scans RootClaimed::iter_prefix. That work is proportional to all historical root-claim state and happens in one on_runtime_upgrade; returning accumulated weight after doing the scan does not bound execution. On a large state this can exceed the upgrade block and stall the chain. Convert this to a bounded/chunked migration or otherwise prove the production state is capped below block limits before merge.

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] One-shot migration scans unbounded root-claim state

on_runtime_upgrade now collects every RootClaimable hotkey and then, for each (hotkey, netuid) slot, scans RootClaimed::iter_prefix. This is unbounded historical state work in a single upgrade block, so a large root-claim ledger can overweight the runtime upgrade and halt block production. Make this a bounded multi-block migration or prove and enforce a hard upper bound before mainnet deployment.

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] One-shot migration scans unbounded root-claim state

This migration runs in on_runtime_upgrade and collects every RootClaimable hotkey, then scans each hotkey's claimable map and each (netuid, hotkey) RootClaimed prefix. That work is proportional to live chain state and has no batch cursor or hard cap, so a large root-claim state can make the upgrade block exceed its weight budget and halt block production. Move this to a bounded multi-block migration or otherwise prove and enforce a state-size cap before running it in the runtime upgrade path.

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::<T>::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::<T>::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::<T>::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::<u64>()
};
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::<T>::increase_stake_for_hotkey_and_coldkey_on_subnet(
hotkey,
&escrow,
*netuid,
remaining_alpha,
);
BasketPrincipal::<T>::insert(hotkey, *netuid, remaining_alpha);
weight.saturating_accrue(T::DbWeight::get().writes(2));
seeded_slots = seeded_slots.saturating_add(1);
}
}

HasMigrationRun::<T>::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
}
1 change: 1 addition & 0 deletions pallets/subtensor/src/migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading