code/pallets/subtensor/src/migrations/migrate_seed_beta_basket.rs
use super::*;
use codec::{Decode, DecodeWithMemTracking, Encode};
use frame_support::pallet_prelude::{Blake2_128Concat, Identity, OptionQuery, ValueQuery};
use frame_support::storage_alias;
use frame_support::weights::Weight;
use scale_info::TypeInfo;
use scale_info::prelude::string::String;
use sp_std::collections::btree_map::BTreeMap;
use sp_std::vec::Vec;
use substrate_fixed::types::{I96F32, U96F32};
use subtensor_runtime_common::{AlphaBalance, NetUid};
use subtensor_swap_interface::SwapHandler;
/// Absolute API ceiling on how many `RootClaimable` hotkeys may be converted in one pass.
/// Normal throughput is lower and is derived from the remaining `on_idle` weight.
pub const MAX_SEED_BETA_BASKET_HOTKEYS_PER_PASS: u32 = u32::MAX;
/// Absolute API ceiling on orphaned `BasketPrincipal` entries cleared in one pass.
/// `StorageDoubleMap::clear` accepts a `u32` limit; the adaptive weight meter remains the
/// effective per-block bound.
pub const MAX_SEED_BETA_BASKET_PRINCIPAL_CLEAR_PER_PASS: u32 = u32::MAX;
/// Absolute API ceiling on `RootClaimed` rows drained while converting hotkeys. A single subnet
/// slot's `(netuid, hotkey)` prefix can hold one row per historical claimant coldkey, which is
/// unbounded in principle. Normal throughput is derived from the remaining `on_idle` weight.
pub const MAX_SEED_BETA_BASKET_CLAIMED_DRAINS_PER_PASS: u32 = u32::MAX;
const MIGRATION_NAME: &[u8] = b"migrate_seed_beta_basket_v2";
pub mod deprecated {
use super::*;
/// Per-slot outstanding basket principal written by the superseded v1 seed migration
/// (`migrate_seed_beta_basket`) and the intermediate per-subnet-slot runtime. No longer
/// declared in the pallet; v2 clears any orphaned entries.
#[storage_alias]
pub type BasketPrincipal<T: Config> = StorageDoubleMap<
Pallet<T>,
Blake2_128Concat,
AccountIdOf<T>,
Identity,
NetUid,
AlphaBalance,
ValueQuery,
>;
}
/// Resumable state for a hotkey whose conversion was paused mid-way because the per-pass
/// `RootClaimed` drain budget ([`MAX_SEED_BETA_BASKET_CLAIMED_DRAINS_PER_PASS`]) ran out before
/// every subnet slot finished draining.
///
/// `RootClaimable[hotkey]` is left untouched in storage for as long as this state exists; it is
/// only `remove`d once every slot has been fully processed, so an interrupted conversion never
/// loses track of a hotkey's still-unconverted slots.
///
/// Valuation inputs (`total_root`, and the in-progress slot's `price`) are snapshotted here and
/// reused on every resume so already-written `BasketClaimed` rows stay consistent with later
/// drains / `fund_rate` / `fund_shares` if stake or moving prices change between blocks.
#[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, Debug, TypeInfo)]
pub struct HotkeyConvertState {
/// SCALE-encoded `T::AccountId` of the hotkey being converted.
pub hotkey: Vec<u8>,
/// Highest `NetUid` (in `RootClaimable[hotkey]`'s `BTreeMap` order) fully converted so far.
/// `None` means no slot has completed yet. Resuming re-reads `RootClaimable[hotkey]`
/// (still present in storage) and skips every entry up to and including this one.
pub done_through: Option<NetUid>,
/// Claimant-base root stake snapshotted at the start of this hotkey's conversion.
pub total_root: I96F32,
/// The subnet slot whose `RootClaimed` drain was paused mid-way, if any:
/// `(netuid, rate, price, claimed_cursor, claimed_sum_so_far)`. `price` is the fixed
/// conversion price for this slot; `claimed_cursor` resumes `RootClaimed::iter_prefix_from`;
/// `claimed_sum_so_far` is the alpha sum of rows already drained for this slot.
pub partial: Option<(NetUid, I96F32, U96F32, Vec<u8>, I96F32)>,
/// Unified rate accumulated from fully-completed slots.
pub fund_rate: I96F32,
/// Unified fund shares accumulated from fully-completed slots.
pub fund_shares: u64,
/// Subnet slots that have contributed outstanding shares so far, for the seeded-slots
/// log/weight accounting.
pub seeded_slots: u64,
}
/// Persistent cursor for the multi-block `migrate_seed_beta_basket_v2` migration.
///
/// Present only while the migration is in progress. Cleared when `HasMigrationRun` is set.
#[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, Debug, TypeInfo)]
pub enum SeedBetaBasketV2Progress {
/// Convert legacy per-hotkey claim state. `after` is the hashed key of the last completed
/// hotkey (`None` = start from the beginning). `iter_keys_from` skips that key. `hotkey` is
/// `Some` when the hotkey after `after` was only partially converted because the per-pass
/// `RootClaimed` drain budget ran out mid-way.
Convert {
after: Option<Vec<u8>>,
hotkey: Option<HotkeyConvertState>,
},
/// Clear the provisional per-slot share supply written by `Convert`. Legacy overclaims can
/// make `Σ_s max(gross_s - claimed_s, 0)` differ from the unified model's
/// `Σ_c max(BasketRate * root_stake_c - BasketClaimed_c, 0)`, so the final supply must be
/// rebuilt from claimant positions rather than retaining the per-slot total.
ClearShares { cursor: Option<Vec<u8>> },
/// Rebuild `BasketShares` from uncapped runtime owed positions. `StakingHotkeys` is the
/// authoritative claimant set used by the claim API; unlike the auxiliary dense coldkey
/// index, it is complete on legacy mainnet state. `after` resumes the outer storage map and
/// `coldkey`/`hotkey_index` resume within one coldkey's vector.
ReconcileClaimants {
after: Option<Vec<u8>>,
coldkey: Option<Vec<u8>>,
hotkey_index: u32,
},
/// Bounded clear of legacy `RootClaimed` rows that had no matching
/// `RootClaimable[hotkey][netuid]` slot. Such watermarks are inert because there is no
/// corresponding accrual rate to convert, but they must still be removed before the
/// migration can declare the legacy maps fully drained.
ClearClaimed { cursor: Option<Vec<u8>> },
/// Bounded clear of orphaned v1 `BasketPrincipal` entries.
ClearPrincipal { cursor: Option<Vec<u8>> },
}
#[storage_alias]
pub type SeedBetaBasketV2Migration<T: Config> =
StorageValue<Pallet<T>, SeedBetaBasketV2Progress, OptionQuery>;
/// Seeds the unified beta-basket fund from pre-existing per-subnet claim state.
///
/// Legacy model: a validator's root dividends accrued as a per-subnet *rate*
/// (`RootClaimable[hotkey][netuid]`, alpha-per-root-stake) with per-subnet claimed watermarks
/// (`RootClaimed[(netuid, hotkey, coldkey)]`), backed by unattributed outstanding alpha in
/// `SubnetAlphaOut`. The beta basket instead is a single *fund* per validator: escrow stake
/// positions `(hotkey, escrow, netuid)` are its holdings, `BasketShares` its outstanding
/// TAO-denominated shares `P`, `BasketRate` the single shares-per-root-stake accumulator, and
/// `BasketClaimed[(hotkey, coldkey)]` the per-staker watermark.
///
/// Conversion fixes each subnet's moving price `p_s` at the migration block (spot fallback for
/// cold EMAs; 1:1 for the root slot) and re-denominates every legacy alpha-unit quantity into
/// TAO-valued fund shares:
///
/// * holdings: the still-outstanding legacy alpha `remaining_s = rate_s * total_root - Σ claimed`
/// is attributed to the validator under the escrow coldkey on subnet `s`;
/// * `BasketRate[hot] = Σ_s rate_s * p_s`
/// * provisional `BasketShares[hot] = Σ_s remaining_s * p_s`
/// * `BasketClaimed[hot, ck] = Σ_s claimed_s(ck) * p_s`
///
/// Legacy state can contain per-subnet overclaims. Flooring each subnet's remaining backing and
/// then aggregating claims into one signed watermark does not commute: the provisional supply can
/// differ from `Σ_c max(BasketRate * root_stake_c - BasketClaimed_c, 0)`. After conversion, a
/// bounded reconciliation therefore clears the provisional supply and rebuilds `BasketShares`
/// from every uncapped runtime claimant position. This guarantees every share is owned exactly
/// once and prevents claim-order-dependent loss. On inconsistent legacy funds the initial
/// `NAV / BasketShares` may differ from one; that share price applies the backing surplus or
/// shortfall pro rata instead of letting early claimants drain later claimants' value.
///
/// The drained legacy maps are cleared so no per-subnet claim state survives.
///
/// ## Chains that already ran the superseded v1 seed migration
///
/// This is **v2** under a fresh `HasMigrationRun` key: the v1 migration
/// (`"migrate_seed_beta_basket"`) seeded the abandoned per-slot `BasketPrincipal` model on dev
/// and test chains, consuming the old key. Reusing the old name would silently skip this
/// migration there and strand every basket. v2 therefore also tolerates v1 state:
///
/// * escrow positions may already exist (v1 staked `remaining` at its run block, and the
/// intermediate runtime compounded/claimed against them). The escrow is only topped up when
/// it holds *less* than the recomputed `remaining`; when it holds more (compounding), the
/// surplus stays and simply carries the old slot's `E/P` multiplier into the fund's `N/P`.
/// * legacy `RootClaimable` may contain root-slot (netuid 0) entries created by the
/// intermediate runtime. These convert at price 1, but never mint a top-up (root has no pool
/// to attribute from); their share contribution is capped at the escrow's actual root stake
/// so shares are never unbacked.
/// * orphaned `BasketPrincipal` entries are cleared.
///
/// ## Multi-block / resumable
///
/// `on_runtime_upgrade` only **kicks off** the seed via [`kickoff_seed_beta_basket_v2`]
/// (writes the cursor, no conversion). That keeps the upgrade hook idempotent for
/// try-runtime's double-execution check. All conversion runs from `on_idle` through
/// [`migrate_seed_beta_basket_v2`].
///
/// Work is chunked across blocks at two levels. Each pass converts at most
/// [`MAX_SEED_BETA_BASKET_HOTKEYS_PER_PASS`] hotkeys, then clears at most
/// [`MAX_SEED_BETA_BASKET_CLAIMED_DRAINS_PER_PASS`] orphaned `RootClaimed` rows and
/// [`MAX_SEED_BETA_BASKET_PRINCIPAL_CLEAR_PER_PASS`] orphaned principal rows. Within the convert
/// phase, the same claimed-row limit bounds how many matching `RootClaimed` rows are drained: a
/// hotkey with many claimant coldkeys on one subnet slot is converted across several passes
/// rather than draining its whole (unbounded) prefix at once.
/// While a hotkey is only partially converted, `RootClaimable[hotkey]` is left untouched in
/// storage and the in-flight state (completed slots, the in-progress slot's drain cursor, and
/// accumulators) is persisted in [`SeedBetaBasketV2Migration`]'s `Convert::hotkey`. Progress is
/// continued from `on_idle` until finished. `HasMigrationRun` is set only when every phase
/// completes.
pub fn migrate_seed_beta_basket_v2<T: Config>() -> Weight {
migrate_seed_beta_basket_v2_with_limit::<T>(Weight::MAX)
}
/// Continue the seed using limits derived from the weight still available to `on_idle`.
///
/// Fixed pass overhead is reserved first. Each work type gets a ceiling derived from the full
/// remaining budget, while the conversion loop uses one shared meter to stop before their
/// combined reported weight reaches `limit`. This lets hotkeys consume capacity unused by
/// claimed rows (and vice versa) without allowing their independent ceilings to add up to an
/// overweight pass. Emergency item ceilings remain as a final guard against a bad weight
/// configuration.
///
/// Progress is deliberately fail-open: every active phase receives a minimum item budget of
/// one even if `limit` is zero or one iteration is individually overweight. In that exceptional
/// case the returned weight may exceed `limit`, but the cursor advances instead of retrying the
/// same oversized item forever.
pub fn migrate_seed_beta_basket_v2_with_limit<T: Config>(limit: Weight) -> Weight {
let overhead = adaptive_pass_overhead::<T>();
let work_budget = limit.saturating_sub(overhead);
let hotkeys = affordable_items(work_budget, hotkey_attempt_weight::<T>())
.max(1)
.min(u64::from(MAX_SEED_BETA_BASKET_HOTKEYS_PER_PASS)) as u32;
let claimed = affordable_items(work_budget, claimed_row_base_weight::<T>())
.max(1)
.min(u64::from(MAX_SEED_BETA_BASKET_CLAIMED_DRAINS_PER_PASS)) as u32;
let principal = affordable_items(work_budget, principal_clear_item_weight::<T>())
.max(1)
.min(u64::from(MAX_SEED_BETA_BASKET_PRINCIPAL_CLEAR_PER_PASS)) as u32;
migrate_seed_beta_basket_v2_inner::<T>(hotkeys, principal, claimed, Some(limit))
}
fn adaptive_pass_overhead<T: Config>() -> Weight {
// HasMigrationRun + cursor + escrow reads, plus enough writes to persist progress or finish.
T::DbWeight::get().reads_writes(3, 2)
}
fn claimed_row_base_weight<T: Config>() -> Weight {
// Iterate/read and remove one RootClaimed row.
T::DbWeight::get().reads_writes(1, 1)
}
fn claimed_coldkey_flush_weight<T: Config>() -> Weight {
// Mutate one aggregated BasketClaimed watermark.
T::DbWeight::get().reads_writes(1, 1)
}
fn claimed_row_worst_case_weight<T: Config>() -> Weight {
// A row plus the first BasketClaimed update for its coldkey in this bounded pass.
claimed_row_base_weight::<T>().saturating_add(claimed_coldkey_flush_weight::<T>())
}
fn hotkey_attempt_weight<T: Config>() -> Weight {
// Conservative fixed storage envelope; per-slot operations are charged as they occur.
T::DbWeight::get().reads_writes(4, 4)
}
fn principal_clear_item_weight<T: Config>() -> Weight {
T::DbWeight::get().reads_writes(1, 1)
}
fn reconcile_coldkey_weight<T: Config>() -> Weight {
// One StakingHotkeys map entry (key plus claimant hotkey vector).
T::DbWeight::get().reads(1)
}
fn reconcile_position_weight<T: Config>() -> Weight {
// Conservative envelope for BasketRate/BasketClaimed, the root share pool's legacy + v2
// numerator/denominator/value reads, and the BasketShares accumulation.
T::DbWeight::get().reads_writes(10, 1)
}
fn affordable_items(budget: Weight, per_item: Weight) -> u64 {
let by_ref_time = budget
.ref_time()
.checked_div(per_item.ref_time())
.unwrap_or(u64::MAX);
let by_proof_size = budget
.proof_size()
.checked_div(per_item.proof_size())
.unwrap_or(u64::MAX);
by_ref_time.min(by_proof_size)
}
fn next_item_fits<T: Config>(used: Weight, item: Weight, limit: Weight) -> bool {
// Keep room to persist a cursor, or to kill it and mark completion.
used.saturating_add(item)
.saturating_add(T::DbWeight::get().writes(2))
.all_lte(limit)
}
/// Idempotent `on_runtime_upgrade` entry: ensure the multi-block seed cursor exists, but do
/// **not** convert any hotkeys. Conversion is owned by `on_idle` via
/// [`migrate_seed_beta_basket_v2`].
///
/// Running conversion inside ORU breaks try-runtime idempotency: the second ORU pass would
/// advance the cursor further and change storage.
pub fn kickoff_seed_beta_basket_v2<T: Config>() -> Weight {
let migration_name = MIGRATION_NAME.to_vec();
let mut weight = T::DbWeight::get().reads(1);
if HasMigrationRun::<T>::get(&migration_name) {
log::info!(
target: "runtime",
"Migration '{}' has already run. Skipping.",
String::from_utf8_lossy(&migration_name)
);
return weight;
}
weight.saturating_accrue(T::DbWeight::get().reads(1));
if SeedBetaBasketV2Migration::<T>::exists() {
// Cursor already present — on_idle owns progress. No-op so a second ORU matches.
return weight;
}
log::info!(
target: "runtime",
"Kicking off migration '{}' (conversion continues on_idle)",
String::from_utf8_lossy(&migration_name)
);
SeedBetaBasketV2Migration::<T>::put(SeedBetaBasketV2Progress::Convert {
after: None,
hotkey: None,
});
weight.saturating_accrue(T::DbWeight::get().writes(1));
weight
}
/// True while the multi-block seed is unfinished (cursor present). Basket deposits and claims
/// must not run while this is set — see `distribute_root_alpha_to_basket` /
/// `do_stake_into_basket` / `do_root_claim`.
pub fn seed_beta_basket_v2_in_progress<T: Config>() -> bool {
SeedBetaBasketV2Migration::<T>::exists()
}
/// Same as [`migrate_seed_beta_basket_v2`] but with explicit per-pass limits (for tests).
pub fn migrate_seed_beta_basket_v2_limited<T: Config>(
hotkeys_per_pass: u32,
principal_clear_per_pass: u32,
claimed_drains_per_pass: u32,
) -> Weight {
migrate_seed_beta_basket_v2_inner::<T>(
hotkeys_per_pass,
principal_clear_per_pass,
claimed_drains_per_pass,
None,
)
}
fn migrate_seed_beta_basket_v2_inner<T: Config>(
hotkeys_per_pass: u32,
principal_clear_per_pass: u32,
claimed_drains_per_pass: u32,
adaptive_limit: Option<Weight>,
) -> Weight {
let migration_name = MIGRATION_NAME.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;
}
weight.saturating_accrue(T::DbWeight::get().reads(1));
let mut progress = match SeedBetaBasketV2Migration::<T>::get() {
Some(p) => p,
None => {
log::info!(
"Running migration '{}'",
String::from_utf8_lossy(&migration_name)
);
// Persist the cursor immediately so `seed_beta_basket_v2_in_progress` is true for
// the rest of this block (and until completion). Basket deposits/claims gate on
// that flag so coinbase cannot write live BasketRate/Shares that a later pass
// would overwrite.
let p = SeedBetaBasketV2Progress::Convert {
after: None,
hotkey: None,
};
SeedBetaBasketV2Migration::<T>::put(&p);
weight.saturating_accrue(T::DbWeight::get().writes(1));
p
}
};
let escrow = Pallet::<T>::get_beta_escrow_account_id();
weight.saturating_accrue(T::DbWeight::get().reads(1));
let mut seeded_slots: u64 = 0;
let mut made_progress = false;
'phases: loop {
match progress {
SeedBetaBasketV2Progress::Convert {
mut after,
mut hotkey,
} => {
let mut claimed_budget = claimed_drains_per_pass;
let mut hotkeys_done_this_pass: u32 = 0;
loop {
let resume_state = hotkey.take();
let current_hotkey: T::AccountId = if let Some(ref state) = resume_state {
match T::AccountId::decode(&mut &state.hotkey[..]) {
Ok(hk) => hk,
Err(_) => {
// Cannot happen in practice (we only ever encode a valid
// `T::AccountId` into this field). Fail closed if storage is
// corrupt: preserve the cursor and all legacy state for explicit
// operator recovery rather than silently declaring completion.
log::error!(
"Migration 'migrate_seed_beta_basket_v2' found an undecodable hotkey cursor; preserving it and stopping this pass."
);
return weight;
}
}
} else {
if hotkeys_done_this_pass >= hotkeys_per_pass {
progress = SeedBetaBasketV2Progress::Convert {
after,
hotkey: None,
};
SeedBetaBasketV2Migration::<T>::put(progress);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused after converting {} hotkey(s); will resume on the next pass.",
hotkeys_done_this_pass
);
return weight;
}
if made_progress
&& adaptive_limit.is_some_and(|limit| {
!next_item_fits::<T>(weight, hotkey_attempt_weight::<T>(), limit)
})
{
progress = SeedBetaBasketV2Progress::Convert {
after,
hotkey: None,
};
SeedBetaBasketV2Migration::<T>::put(progress);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused after converting {} hotkey(s) at its adaptive weight limit; will resume on_idle.",
hotkeys_done_this_pass
);
return weight;
}
let mut keys_iter = match after {
Some(ref raw) => RootClaimable::<T>::iter_keys_from(raw.clone()),
None => RootClaimable::<T>::iter_keys(),
};
match keys_iter.next() {
Some(hk) => {
weight.saturating_accrue(T::DbWeight::get().reads(1));
after = Some(RootClaimable::<T>::hashed_key_for(&hk));
hk
}
None => {
progress = SeedBetaBasketV2Progress::ClearShares { cursor: None };
continue 'phases;
}
}
};
match convert_hotkey_step::<T>(
¤t_hotkey,
&escrow,
resume_state,
&mut claimed_budget,
&mut weight,
adaptive_limit,
&mut made_progress,
) {
ConvertOutcome::Done(slots) => {
seeded_slots = seeded_slots.saturating_add(slots);
hotkeys_done_this_pass = hotkeys_done_this_pass.saturating_add(1);
made_progress = true;
if hotkeys_done_this_pass >= hotkeys_per_pass || claimed_budget == 0 {
progress = SeedBetaBasketV2Progress::Convert {
after,
hotkey: None,
};
SeedBetaBasketV2Migration::<T>::put(progress);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused after converting {} hotkey(s); will resume on the next pass.",
hotkeys_done_this_pass
);
return weight;
}
// Budget remains and the hotkey cap isn't hit: keep converting.
}
ConvertOutcome::Paused(state) => {
progress = SeedBetaBasketV2Progress::Convert {
after,
hotkey: Some(state),
};
SeedBetaBasketV2Migration::<T>::put(progress);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused mid-hotkey after reaching its {} RootClaimed-row/adaptive weight budget; will resume on_idle.",
claimed_drains_per_pass
);
return weight;
}
}
}
}
SeedBetaBasketV2Progress::ClearShares { cursor } => {
let mut clear_limit = principal_clear_per_pass;
if let Some(limit) = adaptive_limit {
let remaining = limit
.saturating_sub(weight)
.saturating_sub(T::DbWeight::get().writes(2));
let affordable =
affordable_items(remaining, principal_clear_item_weight::<T>());
if affordable == 0 && made_progress {
SeedBetaBasketV2Migration::<T>::put(
SeedBetaBasketV2Progress::ClearShares { cursor },
);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused before provisional share cleanup at its adaptive weight limit; will resume on_idle."
);
return weight;
}
clear_limit =
clear_limit.min(affordable.max(1).min(u64::from(u32::MAX)) as u32);
}
let shares_removal = BasketShares::<T>::clear(clear_limit, cursor.as_deref());
weight.saturating_accrue(
T::DbWeight::get()
.reads_writes(shares_removal.loops as u64, shares_removal.backend as u64),
);
made_progress |= shares_removal.backend != 0;
if let Some(next) = shares_removal.maybe_cursor {
progress = SeedBetaBasketV2Progress::ClearShares { cursor: Some(next) };
SeedBetaBasketV2Migration::<T>::put(progress);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused after clearing {} provisional BasketShares entries; will resume on the next pass.",
shares_removal.backend
);
return weight;
}
progress = SeedBetaBasketV2Progress::ReconcileClaimants {
after: None,
coldkey: None,
hotkey_index: 0,
};
continue 'phases;
}
SeedBetaBasketV2Progress::ReconcileClaimants {
mut after,
mut coldkey,
mut hotkey_index,
} => {
let mut coldkeys_done_this_pass = 0u32;
let mut positions_done_this_pass = 0u32;
loop {
if coldkey.is_none() && coldkeys_done_this_pass >= hotkeys_per_pass {
SeedBetaBasketV2Migration::<T>::put(
SeedBetaBasketV2Progress::ReconcileClaimants {
after,
coldkey,
hotkey_index,
},
);
weight.saturating_accrue(T::DbWeight::get().writes(1));
return weight;
}
if coldkey.is_none()
&& made_progress
&& adaptive_limit.is_some_and(|limit| {
!next_item_fits::<T>(weight, reconcile_coldkey_weight::<T>(), limit)
})
{
SeedBetaBasketV2Migration::<T>::put(
SeedBetaBasketV2Progress::ReconcileClaimants {
after,
coldkey,
hotkey_index,
},
);
weight.saturating_accrue(T::DbWeight::get().writes(1));
return weight;
}
let (current_coldkey, hotkeys) = if let Some(encoded) = coldkey.as_ref() {
let current = match T::AccountId::decode(&mut &encoded[..]) {
Ok(current) => current,
Err(_) => {
log::error!(
"Migration 'migrate_seed_beta_basket_v2' found an undecodable claimant cursor; preserving it and stopping this pass."
);
return weight;
}
};
let hotkeys = StakingHotkeys::<T>::get(¤t);
weight.saturating_accrue(T::DbWeight::get().reads(1));
(current, hotkeys)
} else {
let mut iter = match after.as_ref() {
Some(raw) => StakingHotkeys::<T>::iter_from(raw.clone()),
None => StakingHotkeys::<T>::iter(),
};
let Some((current, hotkeys)) = iter.next() else {
progress = SeedBetaBasketV2Progress::ClearClaimed { cursor: None };
continue 'phases;
};
weight.saturating_accrue(T::DbWeight::get().reads(1));
coldkey = Some(current.encode());
hotkey_index = 0;
(current, hotkeys)
};
// The escrow is fund custody, never a claimant.
if current_coldkey == escrow {
after = Some(StakingHotkeys::<T>::hashed_key_for(¤t_coldkey));
coldkey = None;
hotkey_index = 0;
coldkeys_done_this_pass = coldkeys_done_this_pass.saturating_add(1);
made_progress = true;
continue;
}
while (hotkey_index as usize) < hotkeys.len() {
if positions_done_this_pass >= claimed_drains_per_pass
|| (made_progress
&& adaptive_limit.is_some_and(|limit| {
!next_item_fits::<T>(
weight,
reconcile_position_weight::<T>(),
limit,
)
}))
{
SeedBetaBasketV2Migration::<T>::put(
SeedBetaBasketV2Progress::ReconcileClaimants {
after,
coldkey,
hotkey_index,
},
);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused after reconciling {} claimant position(s); will resume on_idle.",
positions_done_this_pass
);
return weight;
}
let Some(hotkey) = hotkeys.get(hotkey_index as usize) else {
break;
};
let owed = Pallet::<T>::get_basket_owed_shares(hotkey, ¤t_coldkey);
if owed != 0 {
BasketShares::<T>::mutate(hotkey, |shares| {
*shares = shares.saturating_add(owed);
});
}
weight.saturating_accrue(reconcile_position_weight::<T>());
hotkey_index = hotkey_index.saturating_add(1);
positions_done_this_pass = positions_done_this_pass.saturating_add(1);
made_progress = true;
}
after = Some(StakingHotkeys::<T>::hashed_key_for(¤t_coldkey));
coldkey = None;
hotkey_index = 0;
coldkeys_done_this_pass = coldkeys_done_this_pass.saturating_add(1);
}
}
SeedBetaBasketV2Progress::ClearClaimed { cursor } => {
let mut clear_limit = claimed_drains_per_pass;
if let Some(limit) = adaptive_limit {
let remaining = limit
.saturating_sub(weight)
.saturating_sub(T::DbWeight::get().writes(2));
let affordable = affordable_items(remaining, claimed_row_base_weight::<T>());
if affordable == 0 && made_progress {
SeedBetaBasketV2Migration::<T>::put(
SeedBetaBasketV2Progress::ClearClaimed { cursor },
);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused before orphaned RootClaimed cleanup at its adaptive weight limit; will resume on_idle."
);
return weight;
}
clear_limit =
clear_limit.min(affordable.max(1).min(u64::from(u32::MAX)) as u32);
}
let claimed_removal = RootClaimed::<T>::clear(clear_limit, cursor.as_deref());
weight.saturating_accrue(
T::DbWeight::get()
.reads_writes(claimed_removal.loops as u64, claimed_removal.backend as u64),
);
made_progress |= claimed_removal.backend != 0;
if let Some(next) = claimed_removal.maybe_cursor {
progress = SeedBetaBasketV2Progress::ClearClaimed { cursor: Some(next) };
SeedBetaBasketV2Migration::<T>::put(progress);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused after clearing {} orphaned RootClaimed entries; will resume on the next pass.",
claimed_removal.backend
);
return weight;
}
progress = SeedBetaBasketV2Progress::ClearPrincipal { cursor: None };
continue 'phases;
}
SeedBetaBasketV2Progress::ClearPrincipal { cursor } => {
let mut clear_limit = principal_clear_per_pass;
if let Some(limit) = adaptive_limit {
let remaining = limit
.saturating_sub(weight)
.saturating_sub(T::DbWeight::get().writes(2));
let affordable =
affordable_items(remaining, principal_clear_item_weight::<T>());
if affordable == 0 && made_progress {
SeedBetaBasketV2Migration::<T>::put(
SeedBetaBasketV2Progress::ClearPrincipal { cursor },
);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused before principal cleanup at its adaptive weight limit; will resume on_idle."
);
return weight;
}
clear_limit =
clear_limit.min(affordable.max(1).min(u64::from(u32::MAX)) as u32);
}
let principal_removal =
deprecated::BasketPrincipal::<T>::clear(clear_limit, cursor.as_deref());
weight.saturating_accrue(T::DbWeight::get().reads_writes(
principal_removal.loops as u64,
principal_removal.backend as u64,
));
if let Some(next) = principal_removal.maybe_cursor {
progress = SeedBetaBasketV2Progress::ClearPrincipal { cursor: Some(next) };
SeedBetaBasketV2Migration::<T>::put(progress);
weight.saturating_accrue(T::DbWeight::get().writes(1));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' paused after clearing {} BasketPrincipal entries; will resume on the next pass.",
principal_removal.backend
);
return weight;
}
// Fully finished.
SeedBetaBasketV2Migration::<T>::kill();
HasMigrationRun::<T>::insert(&migration_name, true);
weight.saturating_accrue(T::DbWeight::get().writes(2));
log::info!(
"Migration 'migrate_seed_beta_basket_v2' completed. Seeded {seeded_slots} slots this pass, cleared {} orphaned BasketPrincipal entries this pass.",
principal_removal.backend
);
return weight;
}
}
}
}
/// Outcome of a single [`convert_hotkey_step`] call.
enum ConvertOutcome {
/// The hotkey's legacy state was fully converted; carries the number of subnet slots that
/// contributed outstanding shares.
Done(u64),
/// The per-pass `RootClaimed` drain budget ran out before the hotkey finished; carries the
/// state needed to resume.
Paused(HotkeyConvertState),
}
/// Convert one hotkey's legacy claim state into the unified fund, resuming from `resume` if
/// given. Bounded by `claimed_budget`, which is decremented for every `RootClaimed` row drained
/// and shared across every hotkey processed in the pass. `RootClaimable[hotkey]` is only
/// `remove`d once every subnet slot has been fully processed — never on a paused/resumed call.
///
/// Per-slot conversion math matches the original one-shot migration body exactly. Claimed-share
/// increments are aggregated by coldkey in memory for this bounded call and flushed before any
/// cursor is persisted, avoiding repeated `BasketClaimed` reads/writes when the same coldkey has
/// legacy watermarks across many subnet slots.
fn convert_hotkey_step<T: Config>(
hotkey: &T::AccountId,
escrow: &T::AccountId,
resume: Option<HotkeyConvertState>,
claimed_budget: &mut u32,
weight: &mut Weight,
adaptive_limit: Option<Weight>,
made_progress: &mut bool,
) -> ConvertOutcome {
// Peeked, not taken: the legacy entry must survive until conversion fully completes.
let claimable = RootClaimable::<T>::get(hotkey);
weight.saturating_accrue(T::DbWeight::get().reads(1));
let (
mut done_through,
mut partial,
mut fund_rate,
mut fund_shares,
mut seeded_slots,
total_root,
) = match resume {
Some(state) => (
state.done_through,
state.partial,
state.fund_rate,
state.fund_shares,
state.seeded_slots,
state.total_root,
),
None => {
// Snapshot once at the start of this hotkey's conversion; resumes reuse it.
let total_root: I96F32 = I96F32::saturating_from_num(
Pallet::<T>::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT).saturating_sub(
// On a v1 chain the escrow may already hold a root-slot position; it is
// custody, not a claimant, so it is excluded from the claimant base.
Pallet::<T>::get_stake_for_hotkey_and_coldkey_on_subnet(
hotkey,
escrow,
NetUid::ROOT,
),
),
);
weight.saturating_accrue(T::DbWeight::get().reads(2));
(
None,
None,
I96F32::saturating_from_num(0),
0u64,
0u64,
total_root,
)
}
};
// Copied once: subsequent slots are always greater in `BTreeMap` order, so the filter never
// needs to observe `done_through` updates made later in this loop.
let initial_done_through = done_through;
let slots = claimable
.iter()
.filter(|(netuid, _)| match initial_done_through {
Some(d) => **netuid > d,
None => true,
});
let mut drained_row_this_step = false;
let mut claimed_increments: BTreeMap<T::AccountId, i128> = BTreeMap::new();
for (netuid, rate) in slots {
// Resume the in-progress slot's partial drain (and its snapshotted price), if this is
// it. Otherwise fix a fresh conversion price for the slot: moving/EMA (manipulation
// resistant), spot fallback for cold EMAs, 1:1 for root.
let (mut claimed_sum, drain_from, price) =
if let Some((p_netuid, p_rate, p_price, cursor, sum)) = partial.take() {
debug_assert_eq!(p_netuid, *netuid);
debug_assert_eq!(p_rate, *rate);
(sum, Some(cursor), p_price)
} else {
let price: U96F32 = if netuid.is_root() {
U96F32::saturating_from_num(1)
} else {
let moving: U96F32 =
U96F32::saturating_from_num(Pallet::<T>::get_moving_alpha_price(*netuid));
if moving > U96F32::saturating_from_num(0) {
moving
} else {
U96F32::saturating_from_num(T::SwapInterface::current_alpha_price(
(*netuid).into(),
))
}
};
weight.saturating_accrue(T::DbWeight::get().reads(1));
(I96F32::saturating_from_num(0), None, price)
};
// Gross credited principal (alpha) = rate * total_root_stake.
let gross: I96F32 = rate.saturating_mul(total_root);
// Total already claimed by all coldkeys on this (netuid, hotkey), converting each
// coldkey's watermark to TAO-valued fund shares while we scan. Bounded: at most
// `claimed_budget` rows are drained (and explicitly removed) per call; the raw storage
// cursor is persisted on pause so draining resumes exactly where it left off.
let mut claimed_iter = match drain_from {
Some(cursor) => RootClaimed::<T>::iter_prefix_from((*netuid, hotkey), cursor),
None => RootClaimed::<T>::iter_prefix((*netuid, hotkey)),
};
let mut budget_exhausted = false;
loop {
if *claimed_budget == 0 {
budget_exhausted = true;
break;
}
if drained_row_this_step
&& adaptive_limit.is_some_and(|limit| {
!next_item_fits::<T>(*weight, claimed_row_worst_case_weight::<T>(), limit)
})
{
budget_exhausted = true;
break;
}
let Some((coldkey, claimed)) = claimed_iter.next() else {
break;
};
claimed_sum = claimed_sum.saturating_add(I96F32::saturating_from_num(claimed));
let claimed_shares: i128 = U96F32::saturating_from_num(claimed)
.saturating_mul(price)
.saturating_to_num::<i128>();
let first_for_coldkey = !claimed_increments.contains_key(&coldkey);
claimed_increments
.entry(coldkey.clone())
.and_modify(|increment| {
*increment = increment.saturating_add(claimed_shares);
})
.or_insert(claimed_shares);
RootClaimed::<T>::remove((*netuid, hotkey, &coldkey));
weight.saturating_accrue(if first_for_coldkey {
// Reserve the later BasketClaimed flush on the first row for this coldkey.
claimed_row_worst_case_weight::<T>()
} else {
claimed_row_base_weight::<T>()
});
*claimed_budget = claimed_budget.saturating_sub(1);
*made_progress = true;
drained_row_this_step = true;
}
if budget_exhausted {
flush_claimed_increments::<T>(hotkey, &mut claimed_increments);
return ConvertOutcome::Paused(HotkeyConvertState {
hotkey: hotkey.encode(),
done_through,
total_root,
partial: Some((
*netuid,
*rate,
price,
claimed_iter.last_raw_key().to_vec(),
claimed_sum,
)),
fund_rate,
fund_shares,
seeded_slots,
});
}
// `RootClaimed` for this slot is fully drained — finalize its contribution.
// Remaining unclaimed (still-outstanding) principal, in alpha.
let remaining_f: I96F32 = gross.saturating_sub(claimed_sum);
let mut remaining: u64 = if remaining_f.is_negative() {
0
} else {
remaining_f.saturating_to_num::<u64>()
};
// Unified rate contribution: the legacy alpha-rate re-denominated to shares at p_s.
// (May be haircut below for an underbacked root slot.)
let mut rate_contribution: I96F32 = rate.saturating_mul(I96F32::saturating_from_num(price));
let existing: u64 =
Pallet::<T>::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, escrow, *netuid)
.to_u64();
weight.saturating_accrue(T::DbWeight::get().reads(1));
if netuid.is_root() {
// Root-slot entries only exist on v1 chains. Root has no pool to attribute
// unbacked alpha from, so the share contribution is capped at the escrow's
// actual root stake (never top up, never mint unbacked shares).
let capped = remaining.min(existing);
if capped < remaining {
// Underbacked (degenerate v1 state): haircut the rate so `Σ owed == P`
// still holds — solve `rate_eff * total_root - claimed_sum == capped`,
// spreading the shortfall pro-rata by stake.
rate_contribution = I96F32::saturating_from_num(capped)
.saturating_add(claimed_sum)
.checked_div(total_root)
.unwrap_or(I96F32::saturating_from_num(0));
}
remaining = capped;
} else if remaining > 0 && existing < remaining {
// Attribute the still-unattributed outstanding alpha to the validator under the
// escrow coldkey. On a fresh (mainnet) chain `existing == 0` and this stakes the
// full `remaining`; on a v1 chain it only tops up any shortfall, and a
// compounded surplus (`existing > remaining`) is left in place so the old slot's
// `E/P` multiplier carries into the fund's `N/P`.
Pallet::<T>::increase_stake_for_hotkey_and_coldkey_on_subnet(
hotkey,
escrow,
*netuid,
AlphaBalance::from(remaining.saturating_sub(existing)),
);
weight.saturating_accrue(T::DbWeight::get().writes(1));
}
fund_rate = fund_rate.saturating_add(rate_contribution);
if remaining != 0 {
// Outstanding fund shares: TAO value of the remaining alpha at p_s.
fund_shares = fund_shares.saturating_add(
U96F32::saturating_from_num(remaining)
.saturating_mul(price)
.saturating_to_num::<u64>(),
);
seeded_slots = seeded_slots.saturating_add(1);
}
done_through = Some(*netuid);
}
flush_claimed_increments::<T>(hotkey, &mut claimed_increments);
// Every subnet slot converted — only now is it safe to drop the legacy claimable entry.
RootClaimable::<T>::remove(hotkey);
weight.saturating_accrue(T::DbWeight::get().writes(1));
if fund_rate != I96F32::saturating_from_num(0) {
BasketRate::<T>::insert(hotkey, fund_rate);
weight.saturating_accrue(T::DbWeight::get().writes(1));
}
if fund_shares != 0 {
BasketShares::<T>::insert(hotkey, fund_shares);
weight.saturating_accrue(T::DbWeight::get().writes(1));
}
ConvertOutcome::Done(seeded_slots)
}
/// Flush the bounded in-memory aggregation. Its read/write weight was reserved when each
/// coldkey first entered the map, so callers must not charge it again here.
fn flush_claimed_increments<T: Config>(
hotkey: &T::AccountId,
increments: &mut BTreeMap<T::AccountId, i128>,
) {
for (coldkey, increment) in sp_std::mem::take(increments) {
if increment != 0 {
BasketClaimed::<T>::mutate(hotkey, &coldkey, |claimed| {
*claimed = claimed.saturating_add(increment);
});
}
}
}