code/pallets/subtensor/src/staking/beta_pricing.rs

beta_pricing.rs

767 lines · 37,182 bytes · da06f03366RawGitHub
//! On-chain standardized beta pricing: index-spliced display prices, staker yield, and
//! total-return stake prices, one source of truth for every consumer (SDK, explorers,
//! EVM).
//!
//! Raw beta prices (`NAV / shares`) carry arbitrary historical baselines: a fund
//! seeded when pools were cheap shows a high price forever, and a fund launched
//! yesterday starts at 1.0 regardless of skill. Two chained index lines fix that,
//! continuing the SDK's frozen historical series with the same formula:
//!
//! * The **bag index** chains display-price relatives: each completed sweep pass
//!   multiplies the previous level by `Σ prev_nav × (display / prev_display) /
//!   Σ prev_nav` over funds sampled at both ends of the period. Relatives weighted by
//!   *start-of-period* NAV make the level flow-neutral by construction: a deposit
//!   changes a fund's size (next period's weight) but no price, so the level moves
//!   with aggregate fund performance (mix skill) only.
//! * The **stake index** chains the same aggregate over total-return stake-price
//!   relatives: the wealth of τ1 of root stake earning the average fund's dividends.
//!   A fund's stake price is `tr_splice × BasketTwr` — the claim-and-restake
//!   accumulator spliced onto the index — so the level compounds dividend flow at
//!   deposit-time pricing and never re-marks past dividends at today's prices.
//!
//! Each fund's [`BetaBaselineOf`] is stamped once at its first share mint (see
//! [`Pallet::stamp_beta_baseline_if_new`]) so both spliced prices start at the index
//! levels of that block. What a level means, for every fund of every age: the wealth
//! of τ1 invested in the average fund at the index epoch, switched into this fund at
//! its birth. Above the index line = beating the market.
//!
//! ## Two marks: spot for per-fund display, realizable for everything permanent
//!
//! Per-fund prices in the live views mark at **spot** (zero-size mark), matching the
//! SDK's display layer: realizable NAV — a full-fund dump — punishes large books for
//! pool impact a normal buyer never pays. Spot is cheap to manipulate on a thin
//! pool, but a manipulated *live* number heals the moment the pool does.
//!
//! Anything that becomes **permanent** must not trust spot: an attacker could pump
//! a thin holding, drag the index, and poison every number derived from it forever.
//! Both permanent artifacts — the chained index relatives folded into each snapshot
//! and the baseline stamped at a fund's birth — therefore mark everything at
//! **realizable** quotes ([`Pallet::realizable_tao_for_alpha`]), which are bounded
//! by pool TAO reserves and cannot be inflated without depositing real value. Every
//! divisor shares this convention, so funds stay comparable; a newborn starts on the
//! index line up to its book's spot-vs-realizable gap (small for liquid books).
//! Money-moving paths keep using realizable NAV exclusively (`basket_views.rs`);
//! nothing in this module sizes a mint or a redemption.
//!
//! ## Bounded work: one paged sweep maintains the chained levels, everyone reads them
//!
//! Valuing every stamped fund is O(funds × holdings) — unbounded, since retired
//! funds keep entries while shares are outstanding. That sweep therefore never runs
//! inline anywhere. Block processing advances a **paged background sweep**
//! ([`Pallet::advance_beta_index_sweep`]): at most
//! [`BETA_INDEX_SWEEP_ROWS_PER_BLOCK`] rows per block, partial relative sums carried
//! in [`BetaIndexSweep`], per-fund start-of-period state in [`BetaIndexFundSample`],
//! and the finished pass published as [`BetaIndexSnapshot`]. A stamp splices onto
//! the latest snapshot — one read plus the fund's own holdings scan — and the
//! pricing runtime APIs read the same snapshot, then scan only the funds they
//! actually price, so a single-fund RPC query costs one fund, not the world.
//! Snapshot staleness is bounded by the refresh interval plus one pass and is
//! benign: the level drifts slowly and stays realizable-marked.
//!
//! Chaining makes the index **path-dependent**: every period's aggregate relative
//! is baked into the level forever, where the old cross-sectional mean would have
//! self-healed. Manipulation resistance is therefore load-bearing at every pass,
//! not just at stamps: relatives mark at **realizable** quotes (bounded by pool TAO
//! reserves — see below) and only count funds above the dust floor at *both* ends
//! of the period, mirroring the SDK's historical builder.
//!
//! ## Fund lives
//!
//! Display state describes one fund *life*. It is born at the first share mint
//! (stamp) and retired when the last share is claimed or when a dust revival starts
//! a new life ([`Pallet::retire_beta_display_state`]) — a revived fund re-mints at
//! par, so an old divisor would splice it to an arbitrary level forever. Invariant:
//! `BetaBaseline` / `BasketTwr` entries exist only while the fund has outstanding
//! shares.
//!
//! ## One canonical series per audience
//!
//! Both pipes reduce to "sample one cumulative series at two blocks":
//!
//! * **Allocators** (bought β): return over `[t0, t1]` is
//!   `display_price(t1) / display_price(t0) - 1`. The raw price is flow-invariant
//!   (deposits mint at NAV, redemptions exit at NAV), so only bag performance moves it.
//! * **Root stakers** (τ on netuid 0): return is `BasketTwr(t1) / BasketTwr(t0) - 1`
//!   — each dividend compounds the accumulator by the value it added per rao of
//!   claimant stake, locked at deposit-time pricing (the claim-and-restake
//!   convention). `stake_price = tr_splice × BasketTwr` is the *same* series spliced
//!   onto the stake index, so `stake_price(t1) / stake_price(t0) - 1` gives the
//!   identical answer; there is no second staker-return model.
//!
//! The rate-delta figure `(BasketRate - rate0) × spot_price` (`staker_yield`) is a
//! **pending-entitlement mark**, not a return series: it re-values every β minted
//! since the stamp at today's price, so it moves with pools even when no dividend
//! lands. Show it as "what the accrued entitlement is worth now", never as yield.
//!
//! Historical samples come from archive state (or these runtime APIs evaluated at a
//! historical block hash), so every consumer reproduces identical numbers.

use super::*;
use crate::rpc_info::basket_info::{BetaPosition, BetaPricing, BetaPricingPage};
use sp_std::collections::btree_map::BTreeMap;
use substrate_fixed::types::{I96F32, U64F64};
use subtensor_swap_interface::SwapHandler;

/// Funds below this NAV (rao, at the sweep's mark) are too shallow to price the live
/// index: they still get display prices, but they do not pull the average. Mirrors the
/// SDK's dust floor (τ0.1).
pub const MIN_INDEX_NAV_RAO: u64 = 100_000_000;

/// Hard per-block budget for the background beta-index sweep, in rows (funds visited
/// plus holdings quoted). The same order as one batched dividend deposit's quote work,
/// so a page never dominates block processing regardless of how many fund lives have
/// accumulated.
pub const BETA_INDEX_SWEEP_ROWS_PER_BLOCK: u64 = 256;

/// Blocks between snapshot refreshes (~2 hours): a completed pass this recent is fresh
/// enough for stamps, so no new pass starts. Staleness at a stamp is bounded by this
/// plus one pass duration.
pub const BETA_INDEX_REFRESH_INTERVAL_BLOCKS: u64 = 600;

/// Hard cap on funds priced by one `get_all_beta_pricing` page (the caller's `limit`
/// is clamped to this) and on positions returned by `get_beta_portfolio`. Pricing a
/// fund quotes each of its holdings, so this is the expensive unit of RPC work.
pub const BETA_PRICING_PAGE_FUNDS: u32 = 256;

/// Hard cap on `BasketShares` rows scanned by one `get_all_beta_pricing` page. Drained
/// fund lives leave zero-share rows behind, which are filtered out but still cost a
/// read each; this bounds that walk so a page's total work stays bounded even on a map
/// full of dead rows.
pub const BETA_PRICING_PAGE_ROWS: u32 = 1024;

/// Per-netuid effective spot price cache for one pricing sweep (τ per alpha; 1.0 for
/// root and non-dynamic mechanisms).
type SpotPriceCache = BTreeMap<NetUid, U64F64>;

/// One fund's resolved display marks, all derived from a single read of its baseline
/// so the divisor convention and the splice formulas cannot fork between the index
/// sweep, fund snapshots, and position views.
struct FundMarks {
    /// Raw-to-display divisor. Never zero, so `raw / divisor` and `amount * divisor`
    /// stay well-defined.
    divisor: U64F64,
    /// `raw / divisor` — the bag mark, comparable across fund ages.
    display: U64F64,
    /// `max(BasketRate - rate0, 0) * raw` — the pending-entitlement mark: the β
    /// minted per rao of root stake since the stamp, valued at today's raw price
    /// (`BasketRate` is denominated in raw β units per rao, so the delta pairs with
    /// the raw price). Re-marks with the price; display only, never part of the
    /// stake series.
    staker_yield: U64F64,
    /// `tr_splice * BasketTwr` — the wealth of τ1 staked at the stamp block under
    /// the claim-and-restake convention, in stake-index units. The TWR locks each
    /// dividend's value when it lands, so past dividends never re-mark.
    stake: U64F64,
    /// Block of the baseline stamp; 0 while provisional.
    first_block: u64,
    /// True while the fund has no frozen baseline: it prices pinned to the current
    /// index levels until its next share mint stamps one.
    provisional: bool,
}

impl<T: Config> Pallet<T> {
    fn cached_spot_price(netuid: NetUid, cache: &mut SpotPriceCache) -> U64F64 {
        if let Some(price) = cache.get(&netuid) {
            return *price;
        }
        let price = if netuid.is_root() || SubnetMechanism::<T>::get(netuid) != 1 {
            U64F64::saturating_from_num(1)
        } else {
            T::SwapInterface::current_alpha_price(netuid.into())
        };
        cache.insert(netuid, price);
        price
    }

    /// A fund's spot-marked NAV in rao and the number of holding rows scanned (for
    /// work pricing). Spot = `Σ price * alpha`, the same zero-size mark for every
    /// fund regardless of book size.
    fn basket_spot_nav_rao(hotkey: &T::AccountId, cache: &mut SpotPriceCache) -> (u64, u64) {
        let mut nav: u64 = 0;
        let mut rows: u64 = 0;
        for (netuid, alpha) in Self::get_basket_holdings(hotkey) {
            rows = rows.saturating_add(1);
            nav = nav.saturating_add(
                Self::cached_spot_price(netuid, cache)
                    .saturating_mul(U64F64::saturating_from_num(alpha.to_u64()))
                    .saturating_to_num::<u64>(),
            );
        }
        (nav, rows)
    }

    /// A fund's realizable NAV in rao and the number of holding rows scanned. Same
    /// valuation as [`Self::get_validator_basket_nav_tao`] (the money-path mark),
    /// with row accounting for callers that price work. `pub(crate)` for the
    /// baseline seed migration, which marks pre-upgrade entitlement at the same
    /// realizable quotes.
    pub(crate) fn basket_realizable_nav_rao(hotkey: &T::AccountId) -> (u64, u64) {
        let mut nav: u64 = 0;
        let mut rows: u64 = 0;
        for (netuid, alpha) in Self::get_basket_holdings(hotkey) {
            rows = rows.saturating_add(1);
            nav = nav.saturating_add(Self::realizable_tao_for_alpha(netuid, alpha.to_u64()));
        }
        (nav, rows)
    }

    /// The pending-entitlement mark: the β minted per rao of root stake since
    /// `rate0`, valued at the current raw price. Re-marks with today's price by
    /// construction — display state, never part of the canonical return series
    /// ([`BasketTwr`]). Shared by live pricing and the baseline seed migration so
    /// the convention cannot fork.
    pub(crate) fn basket_pending_yield(rate: I96F32, rate0: I96F32, raw: U64F64) -> U64F64 {
        let delta = rate.saturating_sub(rate0);
        if delta.is_negative() {
            U64F64::saturating_from_num(0)
        } else {
            U64F64::saturating_from_num(delta).saturating_mul(raw)
        }
    }

    /// A fund's marks against its frozen baseline. Zero-divisor baselines (defensive:
    /// the stamp path clamps to at least one ULP) resolve to divisor 1.
    fn fund_marks_from(
        baseline: &BetaBaselineOf,
        raw: U64F64,
        rate: I96F32,
        twr: U64F64,
    ) -> FundMarks {
        let one = U64F64::saturating_from_num(1);
        let divisor = if baseline.price_divisor == 0 {
            one
        } else {
            baseline.price_divisor
        };
        let display = raw.checked_div(divisor).unwrap_or(raw);
        let staker_yield = Self::basket_pending_yield(rate, baseline.rate0, raw);
        let stake = baseline.tr_splice.saturating_mul(twr);
        FundMarks {
            divisor,
            display,
            staker_yield,
            stake,
            first_block: baseline.first_block,
            provisional: false,
        }
    }

    /// A fund's marks: from its frozen baseline, or *provisionally* pinned to the
    /// given live index levels (zero yield, implied divisor `raw / bag_level`) while
    /// unstamped. One baseline read either way.
    fn fund_marks(
        hotkey: &T::AccountId,
        raw: U64F64,
        bag_level: U64F64,
        stake_level: U64F64,
    ) -> FundMarks {
        if let Some(baseline) = BetaBaseline::<T>::get(hotkey) {
            return Self::fund_marks_from(
                &baseline,
                raw,
                BasketRate::<T>::get(hotkey),
                BasketTwr::<T>::get(hotkey),
            );
        }
        let one = U64F64::saturating_from_num(1);
        let implied = raw.checked_div(bag_level).unwrap_or(one);
        let divisor = if implied == 0 { one } else { implied };
        FundMarks {
            divisor,
            display: raw.checked_div(divisor).unwrap_or(raw),
            staker_yield: U64F64::saturating_from_num(0),
            stake: stake_level,
            first_block: 0,
            provisional: true,
        }
    }

    /// One fund's index-sweep sample at realizable quotes: rows scanned plus, when the
    /// fund is priceable (outstanding shares, nonzero NAV and display price), its
    /// [`BetaIndexFundSampleOf`]. No dust filtering here — the sweep stores every
    /// priceable sample and applies the dust floor to *both* ends when it chains a
    /// relative, mirroring the SDK's historical builder.
    fn index_fund_sample(
        hotkey: &T::AccountId,
        baseline: &BetaBaselineOf,
    ) -> (u64, Option<BetaIndexFundSampleOf>) {
        let shares = BasketShares::<T>::get(hotkey);
        if shares == 0 {
            return (0, None);
        }
        let (nav, rows) = Self::basket_realizable_nav_rao(hotkey);
        let raw = U64F64::saturating_from_num(nav)
            .checked_div(U64F64::saturating_from_num(shares))
            .unwrap_or_default();
        if raw == 0 {
            return (rows, None);
        }
        let marks = Self::fund_marks_from(
            baseline,
            raw,
            BasketRate::<T>::get(hotkey),
            BasketTwr::<T>::get(hotkey),
        );
        if marks.display == 0 || marks.stake == 0 {
            return (rows, None);
        }
        (
            rows,
            Some(BetaIndexFundSampleOf {
                nav,
                display: marks.display,
                stake: marks.stake,
            }),
        )
    }

    /// Fold one fund's period relative into the pass sums: previous-pass NAV weights
    /// `now / previous` for both marks. Only funds above the dust floor at *both* ends
    /// count — too-shallow books price too noisily to move a permanent level.
    fn chain_fund_relative(
        state: &mut BetaIndexSweepOf,
        previous: &BetaIndexFundSampleOf,
        current: &BetaIndexFundSampleOf,
    ) {
        if previous.nav < MIN_INDEX_NAV_RAO || current.nav < MIN_INDEX_NAV_RAO {
            return;
        }
        let (Some(bag_relative), Some(stake_relative)) = (
            current.display.checked_div(previous.display),
            current.stake.checked_div(previous.stake),
        ) else {
            return;
        };
        let weight = U64F64::saturating_from_num(previous.nav);
        state.weight_sum = state.weight_sum.saturating_add(u128::from(previous.nav));
        state.rel_bag_sum = state
            .rel_bag_sum
            .saturating_add(weight.saturating_mul(bag_relative));
        state.rel_stake_sum = state
            .rel_stake_sum
            .saturating_add(weight.saturating_mul(stake_relative));
    }

    /// Publish a completed pass: previous levels × the pass's aggregate relatives.
    /// With no chainable fund (first pass after the upgrade, or an empty index) the
    /// levels carry forward unchanged — 1.0 on a chain that never published. A zero
    /// result (pathological rounding) also carries forward, since a zero level would
    /// pin the chain at zero forever.
    fn publish_beta_index_snapshot(state: &BetaIndexSweepOf, now: u64) {
        let one = U64F64::saturating_from_num(1);
        let (previous_bag, previous_stake) = BetaIndexSnapshot::<T>::get()
            .map(|snapshot| (snapshot.bag_level, snapshot.stake_level))
            .unwrap_or((one, one));
        let mut bag_level = previous_bag;
        let mut stake_level = previous_stake;
        if state.weight_sum > 0 {
            let denom = U64F64::saturating_from_num(state.weight_sum);
            let bag =
                previous_bag.saturating_mul(state.rel_bag_sum.checked_div(denom).unwrap_or(one));
            let stake = previous_stake
                .saturating_mul(state.rel_stake_sum.checked_div(denom).unwrap_or(one));
            if bag > 0 {
                bag_level = bag;
            }
            if stake > 0 {
                stake_level = stake;
            }
        }
        BetaIndexSnapshot::<T>::put(BetaIndexSnapshotOf {
            bag_level,
            stake_level,
            block: now,
        });
    }

    /// [`Self::advance_beta_index_sweep`] wrapped for the `on_initialize` hook: the
    /// page's rows are priced like `stake_into_basket_weight`'s per-holding term (one
    /// read set plus a realizable quote each) plus the per-fund sample read/write, and
    /// the sweep-state reads and writes, so the block declares the work it performs.
    pub(crate) fn advance_beta_index_sweep_weight() -> Weight {
        let rows = Self::advance_beta_index_sweep();
        Weight::from_parts(10_000_000, 1000)
            .saturating_add(T::DbWeight::get().reads_writes(5_u64, 1_u64))
            .saturating_mul(rows)
            .saturating_add(T::DbWeight::get().reads_writes(3_u64, 1_u64))
    }

    /// Advance the background beta-index sweep by one strictly bounded page; runs every
    /// block from `on_initialize`, right after the block step.
    ///
    /// No pass in progress: start one only when the published [`BetaIndexSnapshot`] is
    /// absent or at least [`BETA_INDEX_REFRESH_INTERVAL_BLOCKS`] old. Mid-pass: resume
    /// from the stored cursor. Each visited fund is sampled at realizable quotes; the
    /// sample chains a period relative against the fund's previous-pass
    /// [`BetaIndexFundSample`] (folded into the partial sums, dust-floored at both
    /// ends) and then replaces it as the start-of-period state for the next pass. An
    /// unpriceable fund's stale sample is removed so nothing chains across a gap. The
    /// page stops after [`BETA_INDEX_SWEEP_ROWS_PER_BLOCK`] rows (overshooting only to
    /// finish the fund in hand, so the bound is `page + one fund's holdings`). A
    /// completed pass multiplies the previous levels by the aggregate relatives and
    /// publishes (see [`Self::publish_beta_index_snapshot`]).
    ///
    /// Funds stamped or retired mid-pass may miss one period's relative; the snapshot
    /// is a splice target, not money-path state, so that approximation is benign.
    /// Returns the rows of work performed.
    pub(crate) fn advance_beta_index_sweep() -> u64 {
        let now = Self::get_current_block_as_u64();
        let mut state = match BetaIndexSweep::<T>::get() {
            Some(state) => state,
            None => {
                let fresh = BetaIndexSnapshot::<T>::get().is_some_and(|snapshot| {
                    now.saturating_sub(snapshot.block) < BETA_INDEX_REFRESH_INTERVAL_BLOCKS
                });
                if fresh {
                    return 0;
                }
                BetaIndexSweepOf {
                    cursor: Vec::new(),
                    weight_sum: 0,
                    rel_bag_sum: U64F64::saturating_from_num(0),
                    rel_stake_sum: U64F64::saturating_from_num(0),
                }
            }
        };
        let mut iter = if state.cursor.is_empty() {
            BetaBaseline::<T>::iter()
        } else {
            BetaBaseline::<T>::iter_from(state.cursor.clone())
        };
        let mut work: u64 = 0;
        loop {
            let Some((hotkey, baseline)) = iter.next() else {
                Self::publish_beta_index_snapshot(&state, now);
                BetaIndexSweep::<T>::kill();
                return work;
            };
            work = work.saturating_add(1);
            let (rows, sample) = Self::index_fund_sample(&hotkey, &baseline);
            work = work.saturating_add(rows);
            match sample {
                Some(current) => {
                    if let Some(previous) = BetaIndexFundSample::<T>::get(&hotkey) {
                        Self::chain_fund_relative(&mut state, &previous, &current);
                    }
                    BetaIndexFundSample::<T>::insert(&hotkey, current);
                }
                None => BetaIndexFundSample::<T>::remove(&hotkey),
            }
            if work >= BETA_INDEX_SWEEP_ROWS_PER_BLOCK {
                state.cursor = BetaBaseline::<T>::hashed_key_for(&hotkey);
                BetaIndexSweep::<T>::put(state);
                return work;
            }
        }
    }

    /// The published `(bag index, stake index)` levels — the chained series maintained
    /// by the background sweep; 1.0 while no pass has completed. One storage read. See
    /// the module docs for what each level means.
    pub fn get_beta_index_levels() -> (U64F64, U64F64) {
        let one = U64F64::saturating_from_num(1);
        BetaIndexSnapshot::<T>::get()
            .map(|snapshot| (snapshot.bag_level, snapshot.stake_level))
            .unwrap_or((one, one))
    }

    /// Price one fund against already-computed index levels. An unstamped fund prices
    /// *provisionally*: pinned to the current index levels (zero yield) until its next
    /// share mint stamps a real baseline.
    fn beta_pricing_against(
        hotkey: &T::AccountId,
        shares: u64,
        bag_level: U64F64,
        stake_level: U64F64,
        cache: &mut SpotPriceCache,
    ) -> BetaPricing<T::AccountId> {
        let (spot_nav, _) = Self::basket_spot_nav_rao(hotkey, cache);
        let raw = U64F64::saturating_from_num(spot_nav)
            .checked_div(U64F64::saturating_from_num(shares))
            .unwrap_or_default();
        let marks = Self::fund_marks(hotkey, raw, bag_level, stake_level);
        BetaPricing {
            hotkey: hotkey.clone(),
            spot_price: raw,
            display_price: marks.display,
            stake_price: marks.stake,
            staker_yield: marks.staker_yield,
            staker_twr: BasketTwr::<T>::get(hotkey),
            bag_index: bag_level,
            stake_index: stake_level,
            first_block: marks.first_block,
            provisional: marks.provisional,
            spot_nav_tao: spot_nav.into(),
            shares,
            display_shares: U64F64::saturating_from_num(shares).saturating_mul(marks.divisor),
        }
    }

    /// One fund's standardized pricing snapshot, or `None` when the hotkey has no
    /// outstanding shares. Index levels come from the published [`BetaIndexSnapshot`]
    /// (one read), so the query scans only the requested fund's holdings.
    pub fn get_beta_pricing(hotkey: &T::AccountId) -> Option<BetaPricing<T::AccountId>> {
        let shares = BasketShares::<T>::get(hotkey);
        if shares == 0 {
            return None;
        }
        let mut cache = SpotPriceCache::new();
        let (bag, stake) = Self::get_beta_index_levels();
        Some(Self::beta_pricing_against(
            hotkey, shares, bag, stake, &mut cache,
        ))
    }

    /// One page of pricing snapshots for funds with outstanding shares, all marked
    /// against the same published index snapshot.
    ///
    /// Strictly bounded per call: at most `min(limit, BETA_PRICING_PAGE_FUNDS)` funds
    /// are priced (`limit == 0` means the full page cap) and at most
    /// [`BETA_PRICING_PAGE_ROWS`] `BasketShares` rows are visited. The page carries an
    /// explicit `next` cursor (`None` once the map is exhausted); callers resume by
    /// passing it back as `start_after`, so the full leaderboard is a short loop of
    /// bounded calls instead of one unbounded scan.
    pub fn get_all_beta_pricing(
        start_after: Option<T::AccountId>,
        limit: u32,
    ) -> BetaPricingPage<T::AccountId> {
        let mut cache = SpotPriceCache::new();
        let (bag, stake) = Self::get_beta_index_levels();
        let funds_cap = if limit == 0 {
            BETA_PRICING_PAGE_FUNDS as usize
        } else {
            limit.min(BETA_PRICING_PAGE_FUNDS) as usize
        };
        let mut iter = match &start_after {
            Some(key) => BasketShares::<T>::iter_from(BasketShares::<T>::hashed_key_for(key)),
            None => BasketShares::<T>::iter(),
        };
        let mut pricing = Vec::new();
        let mut scanned: u32 = 0;
        let cursor = loop {
            let Some((hotkey, shares)) = iter.next() else {
                // Map exhausted: the enumeration is complete.
                return BetaPricingPage {
                    pricing,
                    next: None,
                };
            };
            scanned = scanned.saturating_add(1);
            if shares != 0 {
                pricing.push(Self::beta_pricing_against(
                    &hotkey, shares, bag, stake, &mut cache,
                ));
            }
            if pricing.len() >= funds_cap || scanned >= BETA_PRICING_PAGE_ROWS {
                break hotkey;
            }
        };
        // Stopped on a cap: report a resume cursor unless the stop row happened to be
        // the last one (one cheap peek; the peeked row is re-visited next page).
        let next = iter.next().is_some().then_some(cursor);
        BetaPricingPage { pricing, next }
    }

    /// One staker's position on one fund, against precomputed index levels. `None`
    /// when the fund has no shares or the staker no owed β.
    fn beta_position_against(
        hotkey: &T::AccountId,
        coldkey: &T::AccountId,
        bag_level: U64F64,
        stake_level: U64F64,
        cache: &mut SpotPriceCache,
    ) -> Option<BetaPosition<T::AccountId>> {
        let shares_total = BasketShares::<T>::get(hotkey);
        if shares_total == 0 {
            return None;
        }
        let beta = Self::get_basket_owed_shares(hotkey, coldkey).min(shares_total);
        if beta == 0 {
            return None;
        }
        let (spot_nav, _) = Self::basket_spot_nav_rao(hotkey, cache);
        // Realizable NAV prices what a claim would actually pay; the spot mark is for
        // display, mirroring the BetaPricing convention.
        let nav = Self::get_validator_basket_nav_tao(hotkey).to_u64();
        let raw = U64F64::saturating_from_num(spot_nav)
            .checked_div(U64F64::saturating_from_num(shares_total))
            .unwrap_or_default();
        let marks = Self::fund_marks(hotkey, raw, bag_level, stake_level);
        Some(BetaPosition {
            hotkey: hotkey.clone(),
            beta,
            display_beta: U64F64::saturating_from_num(beta).saturating_mul(marks.divisor),
            display_price: marks.display,
            value_tao: Self::basket_payout_from(beta, nav, shares_total).into(),
            spot_value_tao: Self::mul_div_u64(beta, spot_nav, shares_total)
                .min(spot_nav)
                .into(),
            provisional: marks.provisional,
        })
    }

    /// One staker's display-denominated position on one validator's fund, in the same
    /// units as [`BetaPricing`] (`display_beta * display_price` = spot value). `None`
    /// when the staker has no owed β there.
    pub fn get_beta_position(
        hotkey: &T::AccountId,
        coldkey: &T::AccountId,
    ) -> Option<BetaPosition<T::AccountId>> {
        let mut cache = SpotPriceCache::new();
        let (bag, stake) = Self::get_beta_index_levels();
        Self::beta_position_against(hotkey, coldkey, bag, stake, &mut cache)
    }

    /// A coldkey's display-denominated β portfolio: one position per validator on
    /// which it has owed β, all against the same published index snapshot.
    ///
    /// Explicitly capped at [`BETA_PRICING_PAGE_FUNDS`] staking relationships visited,
    /// so one call's work is bounded by the cap times one fund's holdings scan even
    /// for a coldkey with a pathologically long `StakingHotkeys` list.
    pub fn get_beta_portfolio(coldkey: &T::AccountId) -> Vec<BetaPosition<T::AccountId>> {
        let mut cache = SpotPriceCache::new();
        let (bag, stake) = Self::get_beta_index_levels();
        StakingHotkeys::<T>::get(coldkey)
            .into_iter()
            .take(BETA_PRICING_PAGE_FUNDS as usize)
            .filter_map(|hotkey| {
                Self::beta_position_against(&hotkey, coldkey, bag, stake, &mut cache)
            })
            .collect()
    }

    /// Compound the fund's staker total-return accumulator ([`BasketTwr`]) for a
    /// dividend deposit that added `stakers_value` TAO of realizable value across
    /// `total_root` rao of claimant stake: the deposit grew every staked rao by
    /// `stakers_value / total_root`, locked in at the deposit's own pricing. Staker
    /// yield over any window is then a pure ratio of two samples of this series.
    /// This is the *only* staker-return series: `stake_price` is this accumulator
    /// spliced onto the stake index, and the index chains its relatives.
    ///
    /// Display state only — never read by money paths. Called inside the deposit
    /// transaction so a rolled-back mint never compounds the series.
    pub(crate) fn accrue_basket_twr(hotkey: &T::AccountId, stakers_value: u64, total_root: u64) {
        BasketTwr::<T>::mutate(hotkey, |twr| {
            let gain = U64F64::saturating_from_num(stakers_value)
                .checked_div(U64F64::saturating_from_num(total_root))
                .unwrap_or_default();
            *twr = twr.saturating_mul(U64F64::saturating_from_num(1).saturating_add(gain));
        });
    }

    /// Retire a fund's display state at the end of a fund life. The frozen baseline
    /// and the TWR accumulator describe *this* life's history: a revived fund
    /// re-mints at par, so a leftover divisor would splice its new life to an
    /// arbitrary level forever. Called when a claim drains the last share and when a
    /// dust-revival mint starts a new life; the next share mint re-stamps fresh.
    /// Maintains the invariant that display state exists only for funds with
    /// outstanding shares (which is what lets a hotkey swap move it without merge
    /// semantics — see [`Self::transfer_beta_display_state`]).
    pub(crate) fn retire_beta_display_state(hotkey: &T::AccountId) {
        BetaBaseline::<T>::remove(hotkey);
        BasketTwr::<T>::remove(hotkey);
        // The index sample is start-of-period state for the *next* relative; a new
        // life must never chain against the old one's prices.
        BetaIndexFundSample::<T>::remove(hotkey);
    }

    /// Move a fund's display state (frozen baseline, TWR accumulator, and index
    /// sample) to its new hotkey on a hotkey swap. A pure move, never a merge: the clean-root gate in
    /// `do_swap_hotkey` requires the destination to hold zero `BasketShares`, and
    /// display state exists only while a fund has shares (stamped at a mint, retired
    /// on drain or dust revival), so the destination cannot hold either entry.
    pub(crate) fn transfer_beta_display_state(
        old_hotkey: &T::AccountId,
        new_hotkey: &T::AccountId,
    ) {
        if let Some(baseline) = BetaBaseline::<T>::take(old_hotkey) {
            BetaBaseline::<T>::insert(new_hotkey, baseline);
        }
        if BasketTwr::<T>::contains_key(old_hotkey) {
            BasketTwr::<T>::insert(new_hotkey, BasketTwr::<T>::take(old_hotkey));
        }
        if let Some(sample) = BetaIndexFundSample::<T>::take(old_hotkey) {
            BetaIndexFundSample::<T>::insert(new_hotkey, sample);
        }
    }

    /// Stamp a fund's frozen [`BetaBaselineOf`] at its first share mint ("birth"), so
    /// its display and stake prices start at the published index levels. Idempotent
    /// and cheap once stamped (one `contains_key`). Returns the approximate scan work
    /// performed (the fund's own holding rows), for callers that price deposit weight.
    ///
    /// The stamp is permanent, so **everything here marks at realizable quotes** —
    /// the newborn's own raw price and the index levels it splices onto. Spot marks
    /// would let an attacker pump a thin pool (cheaply, recoverably) to poison either
    /// side of the divisor forever; realizable quotes are bounded by pool TAO
    /// reserves, so distorting a stamp requires depositing real value and losing it
    /// to slippage. See the module docs ("Two marks").
    ///
    /// The index levels come from [`BetaIndexSnapshot`] — the paged background sweep's
    /// output — never from an inline sweep, so the stamp's work is bounded by the
    /// fund's own holdings and fits every caller's declared weight envelope (signed
    /// deposits and the block-processing flush both call this). While no snapshot has
    /// been published yet (fresh chain, or the first blocks after the upgrade that
    /// introduced it) the fund stays unstamped and prices provisionally; the next mint
    /// retries. The sweep only sees stamped funds, so a newborn cannot skew its own
    /// splice regardless of timing.
    ///
    /// Called after a successful share mint in both deposit flows, so the fund's
    /// holdings, shares, `BasketRate`, and `BasketTwr` are final for the block.
    pub(crate) fn stamp_beta_baseline_if_new(hotkey: &T::AccountId) -> u64 {
        if BetaBaseline::<T>::contains_key(hotkey) {
            return 0;
        }
        let shares = BasketShares::<T>::get(hotkey);
        if shares == 0 {
            return 0;
        }
        let Some(snapshot) = BetaIndexSnapshot::<T>::get() else {
            return 0;
        };
        let (nav, work) = Self::basket_realizable_nav_rao(hotkey);
        if nav == 0 {
            // Nothing to price against (every holding realizes to zero); leave the
            // fund unstamped so a later, priceable mint stamps it.
            return work;
        }
        let raw = U64F64::saturating_from_num(nav)
            .checked_div(U64F64::saturating_from_num(shares))
            .unwrap_or_default();
        if raw == 0 {
            return work;
        }
        let mut price_divisor = raw.checked_div(snapshot.bag_level).unwrap_or(raw);
        if price_divisor == 0 {
            // Extreme raw/level ratio rounded below one ULP; clamp so later divisions
            // stay defined (the fund then displays saturated-high rather than at zero).
            price_divisor = U64F64::from_bits(1);
        }
        // The stake series is `tr_splice × BasketTwr`. Dividends minted before a
        // (possibly deferred) stamp have already compounded the TWR, so normalize
        // the splice by the TWR at the stamp: the fund's stake price starts exactly
        // at the index level, mirroring how `rate0` excludes pre-stamp entitlement.
        let mut tr_splice = snapshot
            .stake_level
            .checked_div(BasketTwr::<T>::get(hotkey))
            .unwrap_or(snapshot.stake_level);
        if tr_splice == 0 {
            // Same one-ULP clamp as the divisor: a zero splice would pin the fund's
            // stake price at zero for its whole life.
            tr_splice = U64F64::from_bits(1);
        }
        BetaBaseline::<T>::insert(
            hotkey,
            BetaBaselineOf {
                price_divisor,
                rate0: BasketRate::<T>::get(hotkey),
                tr_splice,
                first_block: Self::get_current_block_as_u64(),
            },
        );
        Self::deposit_event(Event::BetaBaselineStamped {
            hotkey: hotkey.clone(),
        });
        work
    }
}