use super::*; use crate::migrations::beta_baseline_table::{ BETA_BASELINES, BETA_INDEX_LEVEL_BITS, BETA_TR_INDEX_LEVEL_BITS, }; use codec::Decode; use frame_support::{traits::Get, weights::Weight}; use log; use scale_info::prelude::string::String; use substrate_fixed::types::{I96F32, U64F64}; /// `HasMigrationRun` key for this migration. pub const MIGRATION_NAME: &[u8] = b"migrate_stamp_beta_baselines"; /// Seeds the on-chain [`BetaBaseline`] map from the frozen SDK baseline table /// (`beta_baseline_table.rs`), so funds that predate on-chain stamping keep the exact /// historical splice the SDK has been displaying: their display divisor, first-sighting /// `BasketRate`, total-return splice level, and first-sighting block. /// /// The seeded `tr_splice` is not the table value verbatim: the live stake series /// (`stake_price = tr_splice × BasketTwr`) only compounds dividends from this upgrade /// onward — the TWR storage starts at its 1.0 default here — while the table's splice /// was sampled at the fund's *first sighting*. The staker history between the two /// exists on-chain only as the fund's rate delta, so the migration folds it in: /// `tr_splice = table_splice × (1 + (BasketRate - rate0) × raw price)`, with the raw /// price marked at the migration block's realizable quotes (the same mark every other /// permanent artifact uses — see `staking/beta_pricing.rs`, "Two marks"). The fund's /// stake price is therefore continuous at the upgrade block and compounds via the TWR /// from then on (see [`seeded_baseline`]). /// /// Only funds that are live on this chain (outstanding `BasketShares`) and not already /// stamped are seeded; everything else in the table is skipped. Funds live on-chain but /// missing from the table (born after the table freeze) are stamped by /// `stamp_beta_baseline_if_new` at their next share mint, exactly like a newborn fund. /// /// When any fund is seeded, the initial [`BetaIndexSnapshot`] is also published from the /// table's frozen index levels, so the on-chain **chained** index continues the SDK's /// historical series (see `staking/beta_pricing.rs`) instead of restarting at 1.0 — the /// baselines were stamped against that series, so evaluating them against any other /// starting level would jump every fund's `vs_index` at the upgrade. On chains where the /// table seeds nothing (testnets, fresh chains) the snapshot is left alone and the index /// starts at 1.0, which is the correct epoch convention there. pub fn migrate_stamp_beta_baselines() -> Weight { let mig_name: Vec = MIGRATION_NAME.to_vec(); let mut total_weight = T::DbWeight::get().reads(1); if HasMigrationRun::::get(&mig_name) { log::info!( "Migration '{}' already executed - skipping", String::from_utf8_lossy(&mig_name) ); return total_weight; } log::info!("Running migration '{}'", String::from_utf8_lossy(&mig_name)); let mut stamped: u64 = 0; for (pubkey, divisor_bits, rate0_bits, tr_splice_bits, first_block) in BETA_BASELINES { // Shares plus existing-baseline reads per table row. total_weight = total_weight.saturating_add(T::DbWeight::get().reads(2)); let Ok(hotkey) = T::AccountId::decode(&mut &pubkey[..]) else { continue; }; // Not a live fund on this chain (e.g. testnets, or the fund fully drained). if BasketShares::::get(&hotkey) == 0 { continue; } if BetaBaseline::::contains_key(&hotkey) { continue; } let (baseline, rows) = seeded_baseline::( &hotkey, *divisor_bits, *rate0_bits, *tr_splice_bits, *first_block, ); // The rate read plus the NAV scan's holding rows (each a storage read and a // realizable pool quote). total_weight = total_weight .saturating_add(T::DbWeight::get().reads(rows.saturating_mul(2).saturating_add(2))); BetaBaseline::::insert(&hotkey, baseline); total_weight = total_weight.saturating_add(T::DbWeight::get().writes(1)); stamped = stamped.saturating_add(1); } total_weight = total_weight.saturating_add(T::DbWeight::get().reads(1)); if stamped > 0 && BetaIndexSnapshot::::get().is_none() { BetaIndexSnapshot::::put(BetaIndexSnapshotOf { bag_level: U64F64::from_bits(BETA_INDEX_LEVEL_BITS), stake_level: U64F64::from_bits(BETA_TR_INDEX_LEVEL_BITS), block: Pallet::::get_current_block_as_u64(), }); total_weight = total_weight.saturating_add(T::DbWeight::get().writes(1)); } HasMigrationRun::::insert(&mig_name, true); total_weight = total_weight.saturating_add(T::DbWeight::get().writes(1)); log::info!( "Migration '{}' completed: stamped {stamped} fund baseline(s)", String::from_utf8_lossy(&mig_name) ); total_weight } /// The exact [`BetaBaselineOf`] this migration seeds for one live table row, plus the /// holding rows scanned to compute it (for weight accounting). Everything but /// `tr_splice` is the frozen table row verbatim; the splice folds in the fund's /// pre-upgrade staker entitlement — `1 + (BasketRate - rate0) × raw price` at the /// migration block's realizable quotes — so the stake series is continuous at the /// upgrade (see the module docs). A fund with no priceable holdings (zero NAV) folds /// by exactly 1: with nothing to value the entitlement against, the table splice is /// kept unchanged. fn seeded_baseline( hotkey: &T::AccountId, divisor_bits: u128, rate0_bits: i128, tr_splice_bits: u128, first_block: u64, ) -> (BetaBaselineOf, u64) { let rate0 = I96F32::from_bits(rate0_bits); let shares = BasketShares::::get(hotkey); let (nav, rows) = Pallet::::basket_realizable_nav_rao(hotkey); let raw = U64F64::saturating_from_num(nav) .checked_div(U64F64::saturating_from_num(shares)) .unwrap_or_default(); let pre_upgrade_yield = Pallet::::basket_pending_yield(BasketRate::::get(hotkey), rate0, raw); let tr_splice = U64F64::from_bits(tr_splice_bits) .saturating_mul(U64F64::saturating_from_num(1).saturating_add(pre_upgrade_yield)); ( BetaBaselineOf { price_divisor: U64F64::from_bits(divisor_bits), rate0, tr_splice, first_block, }, rows, ) } /// The table rows this migration must seed on the current chain: decodable hotkeys that /// are live (outstanding `BasketShares`) and not already stamped, paired with the exact /// [`BetaBaselineOf`] the migration derives for them (the frozen row with the /// pre-upgrade entitlement folded into `tr_splice` — see [`seeded_baseline`]). Empty /// once the migration has run. #[cfg(feature = "try-runtime")] fn expected_seeds() -> Vec<(T::AccountId, BetaBaselineOf)> { if HasMigrationRun::::get(MIGRATION_NAME.to_vec()) { return Vec::new(); } BETA_BASELINES .iter() .filter_map( |(pubkey, divisor_bits, rate0_bits, tr_splice_bits, first_block)| { let hotkey = T::AccountId::decode(&mut &pubkey[..]).ok()?; if BasketShares::::get(&hotkey) == 0 || BetaBaseline::::contains_key(&hotkey) { return None; } let (baseline, _) = seeded_baseline::( &hotkey, *divisor_bits, *rate0_bits, *tr_splice_bits, *first_block, ); Some((hotkey, baseline)) }, ) .collect() } /// [`OnRuntimeUpgrade`](frame_support::traits::OnRuntimeUpgrade) wrapper with try-runtime /// pre/post-upgrade invariant validation, registered in the runtime `Migrations` tuple so /// the try-runtime CI jobs verify the seed against real mainnet/testnet/devnet state. /// /// Validated invariants: every live, unstamped fund in the frozen table gets exactly the /// baseline [`seeded_baseline`] derives from the table (the frozen row with the /// pre-upgrade entitlement folded into `tr_splice`); every baseline that existed before /// the upgrade is untouched; when /// the seed stamps anything on a chain with no published index snapshot, the initial /// snapshot equals the table's frozen index levels (the SDK-series splice); the /// `HasMigrationRun` flag ends set; and (via try-runtime's double-execution check plus /// that flag) the migration is idempotent. pub mod stamp_beta_baselines { use super::*; use frame_support::traits::OnRuntimeUpgrade; use sp_std::marker::PhantomData; #[cfg(feature = "try-runtime")] use codec::Encode; #[cfg(feature = "try-runtime")] use frame_support::ensure; #[cfg(feature = "try-runtime")] use sp_runtime::TryRuntimeError; /// State carried from `pre_upgrade` to `post_upgrade`: the rows the migration must /// seed, every baseline that already existed (and must survive verbatim), and /// whether an index snapshot was already published (in which case the migration /// must not touch it). #[cfg(feature = "try-runtime")] type PreUpgradeState = ( Vec<(::AccountId, BetaBaselineOf)>, Vec<(::AccountId, BetaBaselineOf)>, bool, ); pub struct Migration(PhantomData); impl OnRuntimeUpgrade for Migration { fn on_runtime_upgrade() -> Weight { migrate_stamp_beta_baselines::() } #[cfg(feature = "try-runtime")] fn pre_upgrade() -> Result, TryRuntimeError> { let expected = expected_seeds::(); let preexisting: Vec<(T::AccountId, BetaBaselineOf)> = BetaBaseline::::iter().collect(); let had_snapshot = BetaIndexSnapshot::::get().is_some(); Ok((expected, preexisting, had_snapshot).encode()) } #[cfg(feature = "try-runtime")] fn post_upgrade(state: Vec) -> Result<(), TryRuntimeError> { let (expected, preexisting, had_snapshot): PreUpgradeState = Decode::decode(&mut &state[..]).map_err(|_| "pre_upgrade state must decode")?; ensure!( HasMigrationRun::::get(MIGRATION_NAME.to_vec()), "migrate_stamp_beta_baselines must mark itself as run" ); if !expected.is_empty() && !had_snapshot { let snapshot = BetaIndexSnapshot::::get() .ok_or("a seeding run must publish the initial index snapshot")?; ensure!( snapshot.bag_level == U64F64::from_bits(BETA_INDEX_LEVEL_BITS) && snapshot.stake_level == U64F64::from_bits(BETA_TR_INDEX_LEVEL_BITS), "the initial index snapshot must splice onto the frozen SDK series" ); } for (hotkey, want) in expected { ensure!( BetaBaseline::::get(&hotkey) == Some(want), "every live, unstamped table fund must be seeded with the frozen baseline" ); } for (hotkey, before) in preexisting { ensure!( BetaBaseline::::get(&hotkey) == Some(before), "pre-existing baselines must never be rewritten" ); } Ok(()) } } }