Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/balance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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<RewardTier> {
env.storage()
.instance()
.get(&symbol_short!("rwtiers"))
.unwrap_or(Vec::new(env))
}

pub fn set_reward_tiers(env: &Env, tiers: &Vec<RewardTier>) {
env.storage().instance().set(&symbol_short!("rwtiers"), tiers);
}

pub fn get_rate_history(env: &Env) -> Vec<(u32, u32)> {
env.storage()
.instance()
Expand Down
48 changes: 48 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
124 changes: 120 additions & 4 deletions src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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);
Expand All @@ -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<RewardTier> {
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);
}
Loading