diff --git a/src/balance.rs b/src/balance.rs index 98c7e66..62924f9 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -3,7 +3,7 @@ use crate::storage::{ ContractDelegate, DataKey, DayBucket, DynamicFeeConfig, FeeRecipient, FlashStakeReceipt, GovernanceProposal, InsurancePolicy, InsuranceProduct, Loan, LoanConfig, LotteryConfig, Milestone, MultisigConfig, OnboardingChecklist, PendingAction, PriceCondition, - PriorityBidRecord, RateHistoryEntry, ReferralStats, RevenueShareMerkleRoot, + PriorityBidRecord, RateHistoryEntry, ReferralStats, RewardTier, RevenueShareMerkleRoot, RevenueSharingConfig, Season, StakePosition, SunsetState, VestingEntry, }; @@ -70,6 +70,17 @@ pub fn set_reward_rate_bps(env: &Env, rate_bps: u32) { .set(&DataKey::RewardRateBps, &rate_bps); } +pub fn get_reward_tiers(env: &Env) -> Vec { + env.storage() + .instance() + .get(&symbol_short!("rwtiers")) + .unwrap_or(Vec::new(env)) +} + +pub fn set_reward_tiers(env: &Env, tiers: &Vec) { + env.storage().instance().set(&symbol_short!("rwtiers"), tiers); +} + pub fn get_rate_history(env: &Env) -> Vec<(u32, u32)> { env.storage() .instance() diff --git a/src/storage.rs b/src/storage.rs index ce16088..616eadf 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -139,6 +139,13 @@ pub struct PoolStats { pub total_rewards_paid: i128, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RewardTier { + pub max_amount: i128, + pub rate_bps: i128, +} + /// Aggregate user stats used by `user_stats`. /// /// - `position_amount`: the user's current position size expressed in token units. @@ -325,6 +332,23 @@ pub struct PoolHealthReport { pub is_solvent_7_days: bool, } +/// Admin-only operational snapshot for monitoring and alerting dashboards. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OperatorDashboard { + pub pool_health: PoolHealthReport, + pub staker_count: u32, + pub inactive_staker_count: u32, + pub pending_exit_queue_count: u32, + pub total_ever_staked: i128, + pub total_ever_claimed: i128, + pub largest_position: i128, + pub smallest_active_position: i128, + pub sunset_state: SunsetState, + pub open_governance_proposals: u32, + pub reward_token_runway_days: u32, +} + /// Effective reward rate breakdown returned by `reward_multiplier_preview` (issue #181). /// /// Each `*_multiplier_bps` field defaults to 10000 (1x) if that boost mechanism @@ -1281,6 +1305,13 @@ pub struct PoolStats { pub total_rewards_paid: i128, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RewardTier { + pub max_amount: i128, + pub rate_bps: i128, +} + /// Aggregate user stats used by `user_stats`. /// /// - `position_amount`: the user's current position size expressed in token units. @@ -1467,6 +1498,23 @@ pub struct PoolHealthReport { pub is_solvent_7_days: bool, } +/// Admin-only operational snapshot for monitoring and alerting dashboards. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OperatorDashboard { + pub pool_health: PoolHealthReport, + pub staker_count: u32, + pub inactive_staker_count: u32, + pub pending_exit_queue_count: u32, + pub total_ever_staked: i128, + pub total_ever_claimed: i128, + pub largest_position: i128, + pub smallest_active_position: i128, + pub sunset_state: SunsetState, + pub open_governance_proposals: u32, + pub reward_token_runway_days: u32, +} + /// Effective reward rate breakdown returned by `reward_multiplier_preview` (issue #181). /// /// Each `*_multiplier_bps` field defaults to 10000 (1x) if that boost mechanism diff --git a/src/test.rs b/src/test.rs index 013038b..8fa3068 100644 --- a/src/test.rs +++ b/src/test.rs @@ -13,7 +13,7 @@ use crate::{ nft::{StakeReceiptNFT, StakeReceiptNFTClient}, storage::{ AdminAction, ChangelogEntry, DebtNFT, FeeRecipient, HalvingConfig, MilestoneCondition, PauseReason, - ProposableParam, RoundingPolicy, StakingCertificate, SunsetState, TriggerDirection, + ProposableParam, RewardTier, RoundingPolicy, StakingCertificate, SunsetState, TriggerDirection, UnstakeCheckResult, }, vault::{ @@ -8503,7 +8503,7 @@ fn test_market_lower_rate_wins() { } #[test] -fn test_rate_unchanged_refunds_all() { +fn test_rate_unchanged_refunds_all() { let f = VaultFixture::new(); f.token_admin.mint(&f.alice, &100_000); f.vault.stake(&f.alice, &50_000); @@ -8517,5 +8517,121 @@ fn test_rate_unchanged_refunds_all() { f.vault.resolve_market(&f.admin); let winnings = f.vault.claim_prediction_winnings(&f.alice); - assert_eq!(winnings, 1_000); -} + assert_eq!(winnings, 1_000); +} + +// ── operator dashboard ───────────────────────────────────────────────────── + +#[test] +fn test_operator_dashboard_defaults_for_empty_pool() { + let f = VaultFixture::new(); + let dashboard = f.vault.get_operator_dashboard(); + + assert_eq!(dashboard.pool_health.total_staked, 0); + assert_eq!(dashboard.staker_count, 0); + assert_eq!(dashboard.inactive_staker_count, 0); + assert_eq!(dashboard.pending_exit_queue_count, 0); + assert_eq!(dashboard.total_ever_staked, 0); + assert_eq!(dashboard.total_ever_claimed, 0); + assert_eq!(dashboard.largest_position, 0); + assert_eq!(dashboard.smallest_active_position, 0); + assert_eq!(dashboard.sunset_state, SunsetState::Active); + assert_eq!(dashboard.open_governance_proposals, 0); + assert_eq!(dashboard.reward_token_runway_days, 0); +} + +#[test] +fn test_operator_dashboard_populates_operational_metrics() { + let f = VaultFixture::new(); + setup_reward_pool(&f); + set_ledger(&f.env, 1); + f.vault.stake(&f.alice, &1_000_000); + f.vault.stake(&f.bob, &500_000); + + set_ledger(&f.env, STELLAR_LEDGERS_PER_YEAR); + assert!(f.vault.claim(&f.alice) > 0); + f.vault.set_cooldown_period(&100); + f.vault.request_unstake(&f.alice, &200_000); + f.vault + .create_proposal(&f.bob, &ProposableParam::MinStake, &1_i128, &100_u32); + + let dashboard = f.vault.get_operator_dashboard(); + assert_eq!(dashboard.pool_health.total_staked, 1_300_000); + assert_eq!(dashboard.staker_count, 2); + assert_eq!(dashboard.inactive_staker_count, 1); + assert_eq!(dashboard.pending_exit_queue_count, 1); + assert_eq!(dashboard.total_ever_staked, 1_500_000); + assert!(dashboard.total_ever_claimed > 0); + assert_eq!(dashboard.largest_position, 800_000); + assert_eq!(dashboard.smallest_active_position, 500_000); + assert_eq!(dashboard.sunset_state, SunsetState::Active); + assert_eq!(dashboard.open_governance_proposals, 1); + assert_eq!( + dashboard.reward_token_runway_days, + f.vault.get_reward_token_solvency_ratio() + ); +} + +#[test] +#[should_panic] +fn test_operator_dashboard_requires_admin_auth() { + let f = VaultFixture::with_mock_auths(false); + f.vault.get_operator_dashboard(); +} + +fn reward_tiers(env: &Env) -> Vec { + let mut tiers = Vec::new(env); + tiers.push_back(RewardTier { + max_amount: 1_000, + rate_bps: 1_000, + }); + tiers.push_back(RewardTier { + max_amount: 10_000, + rate_bps: 800, + }); + tiers.push_back(RewardTier { + max_amount: i128::MAX, + rate_bps: 500, + }); + tiers +} + +#[test] +fn test_layered_reward_tiers_first_band_rate() { + let f = VaultFixture::new(); + f.vault.set_reward_tiers(&reward_tiers(&f.env)); + f.vault.stake(&f.alice, &1_000); + set_ledger(&f.env, STELLAR_LEDGERS_PER_YEAR); + + assert_eq!(f.vault.calc_pending_reward(&f.alice), 100); +} + +#[test] +fn test_layered_reward_tiers_split_across_bands() { + let f = VaultFixture::new(); + f.vault.set_reward_tiers(&reward_tiers(&f.env)); + f.vault.stake(&f.alice, &5_000); + set_ledger(&f.env, STELLAR_LEDGERS_PER_YEAR); + + assert_eq!(f.vault.calc_pending_reward(&f.alice), 420); +} + +#[test] +fn test_layered_reward_tiers_blended_effective_rate() { + let f = VaultFixture::new(); + f.vault.set_reward_tiers(&reward_tiers(&f.env)); + + assert_eq!(f.vault.get_effective_rate_for_amount(&5_000), 840); +} + +#[test] +fn test_layered_reward_tiers_empty_uses_flat_rate() { + let f = VaultFixture::new(); + f.vault.set_reward_rate_bps(&700); + f.vault.set_reward_tiers(&Vec::new(&f.env)); + f.vault.stake(&f.alice, &1_000); + set_ledger(&f.env, STELLAR_LEDGERS_PER_YEAR); + + assert_eq!(f.vault.calc_pending_reward(&f.alice), 70); + assert_eq!(f.vault.get_effective_rate_for_amount(&1_000), 700); +} diff --git a/src/vault.rs b/src/vault.rs index bd1d44b..0725265 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -16,8 +16,8 @@ use crate::{ InsurancePolicy, InsuranceProduct, InterfaceId, LeaderboardEntry, Loan, LoanConfig, LotteryConfig, MerkleRoot, MigrationExport, Milestone, MilestoneCondition, MultisigConfig, OptimalClaimAdvice, PauseInfo, PauseReason, PendingAction, PoolComparison, PoolConfig, - PoolHealthReport, PoolStats, PredictionMarket, PriceCondition, PriorityBidRecord, ProposableParam, - RateHistoryEntry, ReferralLeaderboardEntry, ReferralTreeNode, ReputationScore, + OperatorDashboard, PoolHealthReport, PoolStats, PredictionMarket, PriceCondition, PriorityBidRecord, ProposableParam, + RateHistoryEntry, ReferralLeaderboardEntry, ReferralTreeNode, ReputationScore, RewardTier, RewardMultiplierBreakdown, RevenueShareMerkleRoot, RevenueSharingConfig, RoundingPolicy, Season, SmoothingSchedule, SmoothingStatus, StakeAction, StakeHistoryEntry, StakePosition, StakeStreak, StakingCertificate, @@ -2550,6 +2550,63 @@ impl VaultContract { balance::get_reward_rate_bps(&env) } + pub fn set_reward_tiers(env: Env, tiers: Vec) -> Result<(), VaultError> { + admin::require_admin(&env)?; + if tiers.len() > 5 { + return Err(VaultError::TooManyBoostTiers); + } + if !tiers.is_empty() { + let mut previous_max = 0i128; + for i in 0..tiers.len() { + let tier = tiers.get(i).unwrap(); + if tier.max_amount <= previous_max || tier.rate_bps < 0 { + return Err(VaultError::InvalidRate); + } + previous_max = tier.max_amount; + } + if tiers.get(tiers.len() - 1).unwrap().max_amount != i128::MAX { + return Err(VaultError::InvalidRate); + } + } + balance::set_reward_tiers(&env, &tiers); + Ok(()) + } + + pub fn get_reward_tiers(env: Env) -> Vec { + balance::get_reward_tiers(&env) + } + + pub fn get_effective_rate_for_amount(env: Env, amount: i128) -> i128 { + if amount <= 0 { + return 0; + } + let tiers = balance::get_reward_tiers(&env); + if tiers.is_empty() { + return balance::get_reward_rate_bps(&env) as i128; + } + + let mut covered = 0i128; + let mut weighted_rate = 0i128; + for i in 0..tiers.len() { + let tier = tiers.get(i).unwrap(); + let upper = amount.min(tier.max_amount); + if upper > covered { + let band_amount = upper - covered; + weighted_rate = match weighted_rate.checked_add( + band_amount.checked_mul(tier.rate_bps).unwrap_or(0), + ) { + Some(value) => value, + None => return 0, + }; + covered = upper; + } + if covered == amount { + break; + } + } + weighted_rate.checked_div(amount).unwrap_or(0) + } + /// Read-only estimate of the compute cost `stake(user, amount)` would /// incur (issue #204) — no auth, no state changes; only reads /// `user`'s existing share balance to pick a path. @@ -6221,6 +6278,7 @@ impl VaultContract { // as `reward_for_ledgers`, or a season (or campaign) boost would // silently fail to affect real accrual. let segment_dust = Self::reward_dust_for_ledgers( + env, current_shares, seg_rate, current_multiplier, @@ -6249,6 +6307,7 @@ impl VaultContract { } let final_segment_dust = Self::reward_dust_for_ledgers( + env, current_shares, base_rate, current_multiplier, @@ -6300,6 +6359,7 @@ impl VaultContract { /// reward = (100 * 100 * 300) / (10,000 * 6,307,200) = 3,000,000 / 63,072,000_000 = 0. /// To mitigate this value loss, we track the sub-unit remainder (dust) per-user and carry it forward. fn reward_dust_for_ledgers( + env: &Env, amount: i128, rate_bps: u32, multiplier_bps: u32, @@ -6311,24 +6371,66 @@ impl VaultContract { return Ok(0); } - let effective_rate_bps = (rate_bps as i128) + let tiers = balance::get_reward_tiers(env); + if tiers.is_empty() { + return Self::reward_dust_for_rate( + amount, + rate_bps as i128, + multiplier_bps, + campaign_multiplier_bps, + season_multiplier_bps, + elapsed_ledgers, + ); + } + + let mut covered = 0i128; + let mut total_dust = 0i128; + for i in 0..tiers.len() { + let tier = tiers.get(i).unwrap(); + let upper = amount.min(tier.max_amount); + if upper > covered { + total_dust = total_dust + .checked_add(Self::reward_dust_for_rate( + upper - covered, + tier.rate_bps, + multiplier_bps, + campaign_multiplier_bps, + season_multiplier_bps, + elapsed_ledgers, + )?) + .ok_or(VaultError::ArithmeticError)?; + covered = upper; + } + if covered == amount { + break; + } + } + Ok(total_dust) + } + + fn reward_dust_for_rate( + amount: i128, + rate_bps: i128, + multiplier_bps: u32, + campaign_multiplier_bps: u32, + season_multiplier_bps: u32, + elapsed_ledgers: u32, + ) -> Result { + let effective_rate_bps = rate_bps .checked_mul(multiplier_bps as i128) .ok_or(VaultError::ArithmeticError)? .checked_div(BOOST_BPS_BASE as i128) .ok_or(VaultError::ArithmeticError)?; - let boosted_rate_bps = effective_rate_bps .checked_mul(campaign_multiplier_bps as i128) .ok_or(VaultError::ArithmeticError)? .checked_div(BOOST_BPS_BASE as i128) .ok_or(VaultError::ArithmeticError)?; - let seasoned_rate_bps = boosted_rate_bps .checked_mul(season_multiplier_bps as i128) .ok_or(VaultError::ArithmeticError)? .checked_div(BOOST_BPS_BASE as i128) .ok_or(VaultError::ArithmeticError)?; - amount .checked_mul(seasoned_rate_bps) .ok_or(VaultError::ArithmeticError)? @@ -8446,6 +8548,58 @@ impl VaultContract { } } + /// Return all operational metrics needed by an administrator dashboard. + pub fn get_operator_dashboard(env: Env) -> Result { + admin::require_admin(&env)?; + + let current_ledger = env.ledger().sequence(); + let total_shares = balance::get_total_shares(&env); + let total_deposited = balance::get_total_deposited(&env); + let stakers = balance::get_all_stakers(&env); + let mut inactive_staker_count = 0u32; + let mut largest_position = 0i128; + let mut smallest_active_position: Option = None; + + for i in 0..stakers.len() { + let staker = stakers.get(i).unwrap(); + let shares = balance::get_shares(&env, &staker); + if shares == 0 { + continue; + } + + if current_ledger.saturating_sub(balance::get_last_claim_ledger(&env, &staker)) >= 100_000 { + inactive_staker_count = inactive_staker_count.saturating_add(1); + } + + let position = balance::shares_to_amount(total_shares, total_deposited, shares) + .unwrap_or(0); + if position > largest_position { + largest_position = position; + } + if position > 0 && smallest_active_position.map_or(true, |smallest| position < smallest) { + smallest_active_position = Some(position); + } + } + + Ok(OperatorDashboard { + pool_health: Self::pool_health_report(env.clone()), + staker_count: balance::get_total_stakers(&env), + inactive_staker_count, + pending_exit_queue_count: balance::get_exit_queue(&env).len(), + total_ever_staked: balance::get_total_ever_staked(&env), + total_ever_claimed: env + .storage() + .instance() + .get(&DataKey::TotalEverClaimed) + .unwrap_or(0), + largest_position, + smallest_active_position: smallest_active_position.unwrap_or(0), + sunset_state: balance::get_sunset_state(&env), + open_governance_proposals: balance::get_open_proposal_count(&env), + reward_token_runway_days: Self::get_reward_token_solvency_ratio(env), + }) + } + /// Consolidate multiple staking positions into a single position. /// /// For the current scalar share balance layout, this performs a reward accrual step @@ -12588,8 +12742,8 @@ use crate::{ InsurancePolicy, InsuranceProduct, InterfaceId, LeaderboardEntry, Loan, LoanConfig, LotteryConfig, MerkleRoot, MigrationExport, Milestone, MilestoneCondition, MultisigConfig, OptimalClaimAdvice, PauseInfo, PauseReason, PendingAction, PoolComparison, PoolConfig, - PoolHealthReport, PoolStats, PriceCondition, PriorityBidRecord, ProposableParam, - RateHistoryEntry, ReferralLeaderboardEntry, ReferralTreeNode, ReputationScore, + OperatorDashboard, PoolHealthReport, PoolStats, PriceCondition, PriorityBidRecord, ProposableParam, + RateHistoryEntry, ReferralLeaderboardEntry, ReferralTreeNode, ReputationScore, RewardTier, RewardMultiplierBreakdown, RevenueShareMerkleRoot, RevenueSharingConfig, RoundingPolicy, Season, SmoothingSchedule, SmoothingStatus, StakeAction, StakeHistoryEntry, StakePosition, StakeStreak, StakingCertificate, @@ -15115,6 +15269,63 @@ impl VaultContract { balance::get_reward_rate_bps(&env) } + pub fn set_reward_tiers(env: Env, tiers: Vec) -> Result<(), VaultError> { + admin::require_admin(&env)?; + if tiers.len() > 5 { + return Err(VaultError::TooManyBoostTiers); + } + if !tiers.is_empty() { + let mut previous_max = 0i128; + for i in 0..tiers.len() { + let tier = tiers.get(i).unwrap(); + if tier.max_amount <= previous_max || tier.rate_bps < 0 { + return Err(VaultError::InvalidRate); + } + previous_max = tier.max_amount; + } + if tiers.get(tiers.len() - 1).unwrap().max_amount != i128::MAX { + return Err(VaultError::InvalidRate); + } + } + balance::set_reward_tiers(&env, &tiers); + Ok(()) + } + + pub fn get_reward_tiers(env: Env) -> Vec { + balance::get_reward_tiers(&env) + } + + pub fn get_effective_rate_for_amount(env: Env, amount: i128) -> i128 { + if amount <= 0 { + return 0; + } + let tiers = balance::get_reward_tiers(&env); + if tiers.is_empty() { + return balance::get_reward_rate_bps(&env) as i128; + } + + let mut covered = 0i128; + let mut weighted_rate = 0i128; + for i in 0..tiers.len() { + let tier = tiers.get(i).unwrap(); + let upper = amount.min(tier.max_amount); + if upper > covered { + let band_amount = upper - covered; + weighted_rate = match weighted_rate.checked_add( + band_amount.checked_mul(tier.rate_bps).unwrap_or(0), + ) { + Some(value) => value, + None => return 0, + }; + covered = upper; + } + if covered == amount { + break; + } + } + weighted_rate.checked_div(amount).unwrap_or(0) + } + /// Read-only estimate of the compute cost `stake(user, amount)` would /// incur (issue #204) — no auth, no state changes; only reads /// `user`'s existing share balance to pick a path. @@ -18806,6 +19017,7 @@ impl VaultContract { // as `reward_for_ledgers`, or a season (or campaign) boost would // silently fail to affect real accrual. let segment_dust = Self::reward_dust_for_ledgers( + env, current_shares, seg_rate, current_multiplier, @@ -18834,6 +19046,7 @@ impl VaultContract { } let final_segment_dust = Self::reward_dust_for_ledgers( + env, current_shares, base_rate, current_multiplier, @@ -18885,6 +19098,7 @@ impl VaultContract { /// reward = (100 * 100 * 300) / (10,000 * 6,307,200) = 3,000,000 / 63,072,000_000 = 0. /// To mitigate this value loss, we track the sub-unit remainder (dust) per-user and carry it forward. fn reward_dust_for_ledgers( + env: &Env, amount: i128, rate_bps: u32, multiplier_bps: u32, @@ -18896,24 +19110,66 @@ impl VaultContract { return Ok(0); } - let effective_rate_bps = (rate_bps as i128) + let tiers = balance::get_reward_tiers(env); + if tiers.is_empty() { + return Self::reward_dust_for_rate( + amount, + rate_bps as i128, + multiplier_bps, + campaign_multiplier_bps, + season_multiplier_bps, + elapsed_ledgers, + ); + } + + let mut covered = 0i128; + let mut total_dust = 0i128; + for i in 0..tiers.len() { + let tier = tiers.get(i).unwrap(); + let upper = amount.min(tier.max_amount); + if upper > covered { + total_dust = total_dust + .checked_add(Self::reward_dust_for_rate( + upper - covered, + tier.rate_bps, + multiplier_bps, + campaign_multiplier_bps, + season_multiplier_bps, + elapsed_ledgers, + )?) + .ok_or(VaultError::ArithmeticError)?; + covered = upper; + } + if covered == amount { + break; + } + } + Ok(total_dust) + } + + fn reward_dust_for_rate( + amount: i128, + rate_bps: i128, + multiplier_bps: u32, + campaign_multiplier_bps: u32, + season_multiplier_bps: u32, + elapsed_ledgers: u32, + ) -> Result { + let effective_rate_bps = rate_bps .checked_mul(multiplier_bps as i128) .ok_or(VaultError::ArithmeticError)? .checked_div(BOOST_BPS_BASE as i128) .ok_or(VaultError::ArithmeticError)?; - let boosted_rate_bps = effective_rate_bps .checked_mul(campaign_multiplier_bps as i128) .ok_or(VaultError::ArithmeticError)? .checked_div(BOOST_BPS_BASE as i128) .ok_or(VaultError::ArithmeticError)?; - let seasoned_rate_bps = boosted_rate_bps .checked_mul(season_multiplier_bps as i128) .ok_or(VaultError::ArithmeticError)? .checked_div(BOOST_BPS_BASE as i128) .ok_or(VaultError::ArithmeticError)?; - amount .checked_mul(seasoned_rate_bps) .ok_or(VaultError::ArithmeticError)? @@ -21046,6 +21302,58 @@ impl VaultContract { } } + /// Return all operational metrics needed by an administrator dashboard. + pub fn get_operator_dashboard(env: Env) -> Result { + admin::require_admin(&env)?; + + let current_ledger = env.ledger().sequence(); + let total_shares = balance::get_total_shares(&env); + let total_deposited = balance::get_total_deposited(&env); + let stakers = balance::get_all_stakers(&env); + let mut inactive_staker_count = 0u32; + let mut largest_position = 0i128; + let mut smallest_active_position: Option = None; + + for i in 0..stakers.len() { + let staker = stakers.get(i).unwrap(); + let shares = balance::get_shares(&env, &staker); + if shares == 0 { + continue; + } + + if current_ledger.saturating_sub(balance::get_last_claim_ledger(&env, &staker)) >= 100_000 { + inactive_staker_count = inactive_staker_count.saturating_add(1); + } + + let position = balance::shares_to_amount(total_shares, total_deposited, shares) + .unwrap_or(0); + if position > largest_position { + largest_position = position; + } + if position > 0 && smallest_active_position.map_or(true, |smallest| position < smallest) { + smallest_active_position = Some(position); + } + } + + Ok(OperatorDashboard { + pool_health: Self::pool_health_report(env.clone()), + staker_count: balance::get_total_stakers(&env), + inactive_staker_count, + pending_exit_queue_count: balance::get_exit_queue(&env).len(), + total_ever_staked: balance::get_total_ever_staked(&env), + total_ever_claimed: env + .storage() + .instance() + .get(&DataKey::TotalEverClaimed) + .unwrap_or(0), + largest_position, + smallest_active_position: smallest_active_position.unwrap_or(0), + sunset_state: balance::get_sunset_state(&env), + open_governance_proposals: balance::get_open_proposal_count(&env), + reward_token_runway_days: Self::get_reward_token_solvency_ratio(env), + }) + } + /// Consolidate multiple staking positions into a single position. /// /// For the current scalar share balance layout, this performs a reward accrual step